mirror of
https://github.com/TextureGroup/Texture.git
synced 2026-04-07 19:17:39 +00:00
* [ASDataController] Avoid flushing editing queue before starting data controller pipeline Step 1 of the pipeline is on main thread and needs pendingMap which was updated by the end of step 1 of the run. And step 1 operates on data source index space. So it should be ok to start it ASAP -- without waiting for any running work on the background editing queue. One potential race condition after this change is that it's possible for main thread to query the data source (step 1) while the editing queue consumes the last change set (step 3). However, as long as the client's each nodeBlock and the node's layout code capture/reference an individual model object (as opposed to the whole data set) -- which is what clients are supposed to do anyways, then everything should be fine. The benefit of this diff is that the pipeline will be able to accept many more change sets within a short time window, for example when clients submit a burst of separate small updates. I tested this diff against our test suite several times and smoke tested it in our code base without any issues. * Wrap in an experiment * Enable experiment in tests * Minor change
369 lines
13 KiB
Plaintext
369 lines
13 KiB
Plaintext
//
|
|
// ASCollectionModernDataSourceTests.mm
|
|
// Texture
|
|
//
|
|
// Copyright (c) Pinterest, Inc. All rights reserved.
|
|
// Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
|
|
#import <XCTest/XCTest.h>
|
|
#import <OCMock/OCMock.h>
|
|
#import <AsyncDisplayKit/AsyncDisplayKit.h>
|
|
#import <AsyncDisplayKit/NSIndexSet+ASHelpers.h>
|
|
#import "OCMockObject+ASAdditions.h"
|
|
#import "ASTestCase.h"
|
|
|
|
@interface ASCollectionModernDataSourceTests : ASTestCase
|
|
@end
|
|
|
|
@interface ASTestCellNode : ASCellNode
|
|
@end
|
|
|
|
@interface ASTestSection : NSObject <ASSectionContext>
|
|
@property (nonatomic, readonly) NSMutableArray *nodeModels;
|
|
@end
|
|
|
|
@implementation ASCollectionModernDataSourceTests {
|
|
@private
|
|
id mockDataSource;
|
|
UIWindow *window;
|
|
UIViewController *viewController;
|
|
ASCollectionNode *collectionNode;
|
|
NSMutableArray<ASTestSection *> *sections;
|
|
}
|
|
|
|
- (void)setUp {
|
|
[super setUp];
|
|
|
|
ASConfiguration *config = [ASConfiguration new];
|
|
config.experimentalFeatures = ASExperimentalOptimizeDataControllerPipeline;
|
|
[ASConfigurationManager test_resetWithConfiguration:config];
|
|
|
|
// Default is 2 sections: 2 items in first, 1 item in second.
|
|
sections = [NSMutableArray array];
|
|
[sections addObject:[ASTestSection new]];
|
|
[sections[0].nodeModels addObject:[NSObject new]];
|
|
[sections[0].nodeModels addObject:[NSObject new]];
|
|
[sections addObject:[ASTestSection new]];
|
|
[sections[1].nodeModels addObject:[NSObject new]];
|
|
window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
|
|
viewController = [[UIViewController alloc] init];
|
|
|
|
window.rootViewController = viewController;
|
|
[window makeKeyAndVisible];
|
|
collectionNode = [[ASCollectionNode alloc] initWithCollectionViewLayout:[UICollectionViewFlowLayout new]];
|
|
collectionNode.frame = viewController.view.bounds;
|
|
collectionNode.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
|
[viewController.view addSubnode:collectionNode];
|
|
|
|
mockDataSource = OCMStrictProtocolMock(@protocol(ASCollectionDataSource));
|
|
[mockDataSource addImplementedOptionalProtocolMethods:
|
|
@selector(numberOfSectionsInCollectionNode:),
|
|
@selector(collectionNode:numberOfItemsInSection:),
|
|
@selector(collectionNode:nodeBlockForItemAtIndexPath:),
|
|
@selector(collectionNode:nodeModelForItemAtIndexPath:),
|
|
@selector(collectionNode:contextForSection:),
|
|
nil];
|
|
[mockDataSource setExpectationOrderMatters:YES];
|
|
|
|
// NOTE: Adding optionally-implemented methods after this point won't work due to ASCollectionNode selector caching.
|
|
collectionNode.dataSource = mockDataSource;
|
|
}
|
|
|
|
- (void)tearDown
|
|
{
|
|
[collectionNode waitUntilAllUpdatesAreProcessed];
|
|
[super tearDown];
|
|
}
|
|
|
|
#pragma mark - Test Methods
|
|
|
|
- (void)testInitialDataLoading
|
|
{
|
|
[self loadInitialData];
|
|
}
|
|
|
|
- (void)testReloadingAnItem
|
|
{
|
|
[self loadInitialData];
|
|
|
|
// Reload at (0, 0)
|
|
NSIndexPath *reloadedPath = [NSIndexPath indexPathForItem:0 inSection:0];
|
|
|
|
[self performUpdateReloadingSections:nil
|
|
reloadingItems:@{ reloadedPath: [NSObject new] }
|
|
reloadMappings:@{ reloadedPath: reloadedPath }
|
|
insertingItems:nil
|
|
deletingItems:nil
|
|
skippedReloadIndexPaths:nil];
|
|
}
|
|
|
|
- (void)testInsertingAnItem
|
|
{
|
|
[self loadInitialData];
|
|
|
|
// Insert at (1, 0)
|
|
NSIndexPath *insertedPath = [NSIndexPath indexPathForItem:0 inSection:1];
|
|
|
|
[self performUpdateReloadingSections:nil
|
|
reloadingItems:nil
|
|
reloadMappings:nil
|
|
insertingItems:@{ insertedPath: [NSObject new] }
|
|
deletingItems:nil
|
|
skippedReloadIndexPaths:nil];
|
|
}
|
|
|
|
- (void)testReloadingAnItemWithACompatibleNodeModel
|
|
{
|
|
[self loadInitialData];
|
|
|
|
// Reload and delete together, for good measure.
|
|
NSIndexPath *reloadedPath = [NSIndexPath indexPathForItem:1 inSection:0];
|
|
NSIndexPath *deletedPath = [NSIndexPath indexPathForItem:0 inSection:0];
|
|
|
|
id nodeModel = [NSObject new];
|
|
|
|
// Cell node should get -canUpdateToNodeModel:
|
|
id mockCellNode = [collectionNode nodeForItemAtIndexPath:reloadedPath];
|
|
OCMExpect([mockCellNode canUpdateToNodeModel:nodeModel])
|
|
.andReturn(YES);
|
|
|
|
[self performUpdateReloadingSections:nil
|
|
reloadingItems:@{ reloadedPath: nodeModel }
|
|
reloadMappings:@{ reloadedPath: [NSIndexPath indexPathForItem:0 inSection:0] }
|
|
insertingItems:nil
|
|
deletingItems:@[ deletedPath ]
|
|
skippedReloadIndexPaths:@[ reloadedPath ]];
|
|
}
|
|
|
|
- (void)testReloadingASection
|
|
{
|
|
[self loadInitialData];
|
|
|
|
[self performUpdateReloadingSections:@{ @0: [ASTestSection new] }
|
|
reloadingItems:nil
|
|
reloadMappings:nil
|
|
insertingItems:nil
|
|
deletingItems:nil
|
|
skippedReloadIndexPaths:nil];
|
|
}
|
|
|
|
#pragma mark - Helpers
|
|
|
|
- (void)loadInitialData
|
|
{
|
|
// Count methods are called twice in a row for first data load.
|
|
// Since -reloadData is routed through our batch update system,
|
|
// the batch update latches the "old data source counts" if needed at -beginUpdates time
|
|
// and then verifies them against the "new data source counts" after the updates.
|
|
// This isn't ideal, but the cost is very small and the system works well.
|
|
for (int i = 0; i < 2; i++) {
|
|
// It reads all the counts
|
|
[self expectDataSourceCountMethods];
|
|
}
|
|
|
|
// It reads each section object.
|
|
for (NSInteger section = 0; section < sections.count; section++) {
|
|
[self expectContextMethodForSection:section];
|
|
}
|
|
|
|
// It reads the contents for each item.
|
|
for (NSInteger section = 0; section < sections.count; section++) {
|
|
NSArray *nodeModels = sections[section].nodeModels;
|
|
|
|
// For each item:
|
|
for (NSInteger i = 0; i < nodeModels.count; i++) {
|
|
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:section];
|
|
[self expectNodeModelMethodForItemAtIndexPath:indexPath nodeModel:nodeModels[i]];
|
|
[self expectNodeBlockMethodForItemAtIndexPath:indexPath];
|
|
}
|
|
}
|
|
|
|
[window layoutIfNeeded];
|
|
|
|
// Assert item counts & content:
|
|
[self assertCollectionNodeContent];
|
|
}
|
|
|
|
/**
|
|
* Adds expectations for the sequence:
|
|
*
|
|
* numberOfSectionsInCollectionNode:
|
|
* for section in countsArray
|
|
* numberOfItemsInSection:
|
|
*/
|
|
- (void)expectDataSourceCountMethods
|
|
{
|
|
// -numberOfSectionsInCollectionNode
|
|
OCMExpect([mockDataSource numberOfSectionsInCollectionNode:collectionNode])
|
|
.andReturn(sections.count);
|
|
|
|
// For each section:
|
|
// Note: Skip fast enumeration for readability.
|
|
for (NSInteger section = 0; section < sections.count; section++) {
|
|
OCMExpect([mockDataSource collectionNode:collectionNode numberOfItemsInSection:section])
|
|
.andReturn(sections[section].nodeModels.count);
|
|
}
|
|
}
|
|
|
|
- (void)expectNodeModelMethodForItemAtIndexPath:(NSIndexPath *)indexPath nodeModel:(id)nodeModel
|
|
{
|
|
OCMExpect([mockDataSource collectionNode:collectionNode nodeModelForItemAtIndexPath:indexPath])
|
|
.andReturn(nodeModel);
|
|
}
|
|
|
|
- (void)expectContextMethodForSection:(NSInteger)section
|
|
{
|
|
OCMExpect([mockDataSource collectionNode:collectionNode contextForSection:section])
|
|
.andReturn(sections[section]);
|
|
}
|
|
|
|
- (void)expectNodeBlockMethodForItemAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
OCMExpect([mockDataSource collectionNode:collectionNode nodeBlockForItemAtIndexPath:indexPath])
|
|
.andReturn((ASCellNodeBlock)^{
|
|
ASCellNode *node = [ASTestCellNode new];
|
|
// Generating multiple partial mocks of the same class is not thread-safe.
|
|
id mockNode;
|
|
@synchronized (NSNull.null) {
|
|
mockNode = OCMPartialMock(node);
|
|
}
|
|
[mockNode setExpectationOrderMatters:YES];
|
|
return mockNode;
|
|
});
|
|
}
|
|
|
|
/// Asserts that counts match and all view-models are up-to-date between us and collectionNode.
|
|
- (void)assertCollectionNodeContent
|
|
{
|
|
// Assert section count
|
|
XCTAssertEqual(collectionNode.numberOfSections, sections.count);
|
|
|
|
for (NSInteger section = 0; section < sections.count; section++) {
|
|
ASTestSection *sectionObject = sections[section];
|
|
NSArray *nodeModels = sectionObject.nodeModels;
|
|
|
|
// Assert section object
|
|
XCTAssertEqualObjects([collectionNode contextForSection:section], sectionObject);
|
|
|
|
// Assert item count
|
|
XCTAssertEqual([collectionNode numberOfItemsInSection:section], nodeModels.count);
|
|
for (NSInteger item = 0; item < nodeModels.count; item++) {
|
|
// Assert node model
|
|
// Could use pointer equality but the error message is less readable.
|
|
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:item inSection:section];
|
|
id nodeModel = nodeModels[indexPath.item];
|
|
XCTAssertEqualObjects(nodeModel, [collectionNode nodeModelForItemAtIndexPath:indexPath]);
|
|
ASCellNode *node = [collectionNode nodeForItemAtIndexPath:indexPath];
|
|
XCTAssertEqualObjects(node.nodeModel, nodeModel);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the collection node, with expectations and assertions about the call-order and the correctness of the
|
|
* new data. You should update the data source _before_ calling this method.
|
|
*
|
|
* skippedReloadIndexPaths are the old index paths for nodes that should use -canUpdateToNodeModel: instead of being refetched.
|
|
*/
|
|
- (void)performUpdateReloadingSections:(NSDictionary<NSNumber *, id> *)reloadedSections
|
|
reloadingItems:(NSDictionary<NSIndexPath *, id> *)reloadedItems
|
|
reloadMappings:(NSDictionary<NSIndexPath *, NSIndexPath *> *)reloadMappings
|
|
insertingItems:(NSDictionary<NSIndexPath *, id> *)insertedItems
|
|
deletingItems:(NSArray<NSIndexPath *> *)deletedItems
|
|
skippedReloadIndexPaths:(NSArray<NSIndexPath *> *)skippedReloadIndexPaths
|
|
{
|
|
[collectionNode performBatchUpdates:^{
|
|
// First update our data source.
|
|
[reloadedItems enumerateKeysAndObjectsUsingBlock:^(NSIndexPath * _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
|
|
sections[key.section].nodeModels[key.item] = obj;
|
|
}];
|
|
[reloadedSections enumerateKeysAndObjectsUsingBlock:^(NSNumber * _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
|
|
sections[key.integerValue] = obj;
|
|
}];
|
|
|
|
// Deletion paths, sorted descending
|
|
for (NSIndexPath *indexPath in [deletedItems sortedArrayUsingSelector:@selector(compare:)].reverseObjectEnumerator) {
|
|
[sections[indexPath.section].nodeModels removeObjectAtIndex:indexPath.item];
|
|
}
|
|
|
|
// Insertion paths, sorted ascending.
|
|
NSArray *insertionsSortedAcending = [insertedItems.allKeys sortedArrayUsingSelector:@selector(compare:)];
|
|
for (NSIndexPath *indexPath in insertionsSortedAcending) {
|
|
[sections[indexPath.section].nodeModels insertObject:insertedItems[indexPath] atIndex:indexPath.item];
|
|
}
|
|
|
|
// Then update the collection node.
|
|
NSMutableIndexSet *reloadedSectionIndexes = [NSMutableIndexSet indexSet];
|
|
for (NSNumber *i in reloadedSections) {
|
|
[reloadedSectionIndexes addIndex:i.integerValue];
|
|
}
|
|
[collectionNode reloadSections:reloadedSectionIndexes];
|
|
[collectionNode reloadItemsAtIndexPaths:reloadedItems.allKeys];
|
|
[collectionNode deleteItemsAtIndexPaths:deletedItems];
|
|
[collectionNode insertItemsAtIndexPaths:insertedItems.allKeys];
|
|
|
|
// Before the commit, lay out our expectations.
|
|
|
|
// Expect it to load the new counts.
|
|
[self expectDataSourceCountMethods];
|
|
|
|
// Combine reloads + inserts and expect them to load content for all of them, in ascending order.
|
|
NSMutableDictionary<NSIndexPath *, id> *insertsPlusReloads = [[NSMutableDictionary alloc] initWithDictionary:insertedItems];
|
|
|
|
// Go through reloaded sections and add all their items into `insertsPlusReloads`
|
|
[reloadedSectionIndexes enumerateIndexesUsingBlock:^(NSUInteger section, BOOL * _Nonnull stop) {
|
|
[self expectContextMethodForSection:section];
|
|
NSArray *nodeModels = sections[section].nodeModels;
|
|
for (NSInteger i = 0; i < nodeModels.count; i++) {
|
|
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:section];
|
|
insertsPlusReloads[indexPath] = nodeModels[i];
|
|
}
|
|
}];
|
|
|
|
[reloadedItems enumerateKeysAndObjectsUsingBlock:^(NSIndexPath * _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
|
|
insertsPlusReloads[reloadMappings[key]] = obj;
|
|
}];
|
|
|
|
for (NSIndexPath *indexPath in [insertsPlusReloads.allKeys sortedArrayUsingSelector:@selector(compare:)]) {
|
|
[self expectNodeModelMethodForItemAtIndexPath:indexPath nodeModel:insertsPlusReloads[indexPath]];
|
|
NSIndexPath *oldIndexPath = [reloadMappings allKeysForObject:indexPath].firstObject;
|
|
BOOL isSkippedReload = oldIndexPath && [skippedReloadIndexPaths containsObject:oldIndexPath];
|
|
if (!isSkippedReload) {
|
|
[self expectNodeBlockMethodForItemAtIndexPath:indexPath];
|
|
}
|
|
}
|
|
} completion:nil];
|
|
|
|
// Assert that the counts and node models are all correct now.
|
|
[self assertCollectionNodeContent];
|
|
}
|
|
|
|
@end
|
|
|
|
#pragma mark - Other Objects
|
|
|
|
@implementation ASTestCellNode
|
|
|
|
- (BOOL)canUpdateToNodeModel:(id)nodeModel
|
|
{
|
|
// Our tests default to NO for migrating node models. We use OCMExpect to return YES when we specifically want to.
|
|
return NO;
|
|
}
|
|
|
|
@end
|
|
|
|
@implementation ASTestSection
|
|
@synthesize collectionView;
|
|
@synthesize sectionName;
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super init]) {
|
|
_nodeModels = [NSMutableArray array];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
@end
|