diff --git a/AsyncDisplayKit.xcodeproj/project.pbxproj b/AsyncDisplayKit.xcodeproj/project.pbxproj index 8ed10ccad0..62c3baffc7 100644 --- a/AsyncDisplayKit.xcodeproj/project.pbxproj +++ b/AsyncDisplayKit.xcodeproj/project.pbxproj @@ -545,6 +545,7 @@ CC3B208C1C3F7A5400798563 /* ASWeakSet.m in Sources */ = {isa = PBXBuildFile; fileRef = CC3B20881C3F7A5400798563 /* ASWeakSet.m */; }; CC3B208E1C3F7D0A00798563 /* ASWeakSetTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC3B208D1C3F7D0A00798563 /* ASWeakSetTests.m */; }; CC3B20901C3F892D00798563 /* ASBridgedPropertiesTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = CC3B208F1C3F892D00798563 /* ASBridgedPropertiesTests.mm */; }; + CC4981B31D1A02BE004E13CC /* ASTableViewThrashTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC4981B21D1A02BE004E13CC /* ASTableViewThrashTests.m */; }; CC7FD9DE1BB5E962005CCB2B /* ASPhotosFrameworkImageRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = CC7FD9DC1BB5E962005CCB2B /* ASPhotosFrameworkImageRequest.h */; settings = {ATTRIBUTES = (Public, ); }; }; CC7FD9DF1BB5E962005CCB2B /* ASPhotosFrameworkImageRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = CC7FD9DD1BB5E962005CCB2B /* ASPhotosFrameworkImageRequest.m */; }; CC7FD9E11BB5F750005CCB2B /* ASPhotosFrameworkImageRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC7FD9E01BB5F750005CCB2B /* ASPhotosFrameworkImageRequestTests.m */; }; @@ -935,6 +936,7 @@ CC3B20881C3F7A5400798563 /* ASWeakSet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASWeakSet.m; sourceTree = ""; }; CC3B208D1C3F7D0A00798563 /* ASWeakSetTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASWeakSetTests.m; sourceTree = ""; }; CC3B208F1C3F892D00798563 /* ASBridgedPropertiesTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ASBridgedPropertiesTests.mm; sourceTree = ""; }; + CC4981B21D1A02BE004E13CC /* ASTableViewThrashTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASTableViewThrashTests.m; sourceTree = ""; }; CC7FD9DC1BB5E962005CCB2B /* ASPhotosFrameworkImageRequest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASPhotosFrameworkImageRequest.h; sourceTree = ""; }; CC7FD9DD1BB5E962005CCB2B /* ASPhotosFrameworkImageRequest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASPhotosFrameworkImageRequest.m; sourceTree = ""; }; CC7FD9E01BB5F750005CCB2B /* ASPhotosFrameworkImageRequestTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASPhotosFrameworkImageRequestTests.m; sourceTree = ""; }; @@ -1201,6 +1203,7 @@ 052EE0651A159FEF002C6279 /* ASMultiplexImageNodeTests.m */, 058D0A32195D057000B7D73C /* ASMutableAttributedStringBuilderTests.m */, 3C9C128419E616EF00E942A0 /* ASTableViewTests.m */, + CC4981B21D1A02BE004E13CC /* ASTableViewThrashTests.m */, 058D0A33195D057000B7D73C /* ASTextKitCoreTextAdditionsTests.m */, 254C6B511BF8FE6D003EC431 /* ASTextKitTruncationTests.mm */, 254C6B531BF8FF2A003EC431 /* ASTextKitTests.mm */, @@ -2171,6 +2174,7 @@ 2538B6F31BC5D2A2003CA0B4 /* ASCollectionViewFlowLayoutInspectorTests.m in Sources */, 058D0A39195D057000B7D73C /* ASDisplayNodeAppearanceTests.m in Sources */, 058D0A3A195D057000B7D73C /* ASDisplayNodeTests.m in Sources */, + CC4981B31D1A02BE004E13CC /* ASTableViewThrashTests.m in Sources */, 058D0A3B195D057000B7D73C /* ASDisplayNodeTestsHelper.m in Sources */, 056D21551ABCEF50001107EF /* ASImageNodeSnapshotTests.m in Sources */, AC026B581BD3F61800BBC17E /* ASStaticLayoutSpecSnapshotTests.m in Sources */, diff --git a/AsyncDisplayKit/Private/ASMultidimensionalArrayUtils.mm b/AsyncDisplayKit/Private/ASMultidimensionalArrayUtils.mm index 2652912915..5c7e437387 100644 --- a/AsyncDisplayKit/Private/ASMultidimensionalArrayUtils.mm +++ b/AsyncDisplayKit/Private/ASMultidimensionalArrayUtils.mm @@ -41,7 +41,7 @@ static void ASRecursivelyUpdateMultidimensionalArrayAtIndexPaths(NSMutableArray } } -static void ASRecursivelyFindIndexPathsForMultidimensionalArray(NSObject *obj, NSIndexPath *curIndexPath, NSMutableArray *res) +static void ASRecursivelyFindIndexPathsForMultidimensionalArray(NSObject *obj, NSIndexPath *curIndexPath, NSMutableArray *res) { if (![obj isKindOfClass:[NSArray class]]) { [res addObject:curIndexPath]; diff --git a/AsyncDisplayKitTests/ASTableViewThrashTests.m b/AsyncDisplayKitTests/ASTableViewThrashTests.m new file mode 100644 index 0000000000..79cf4ef084 --- /dev/null +++ b/AsyncDisplayKitTests/ASTableViewThrashTests.m @@ -0,0 +1,391 @@ +// +// ASTableViewThrashTests.m +// AsyncDisplayKit +// +// Created by Adlai Holler on 6/21/16. +// Copyright © 2016 Facebook. All rights reserved. +// + +@import XCTest; +#import + +typedef NS_ENUM(NSUInteger, ASThrashChangeType) { + ASThrashReplaceItem, + ASThrashReplaceSection, + ASThrashDeleteItem, + ASThrashDeleteSection, + ASThrashInsertItem, + ASThrashInsertSection +}; + +#define USE_UIKIT_REFERENCE 1 +#define kInitialSectionCount 6 +#define kInitialItemCount 6 + +#if USE_UIKIT_REFERENCE +#define kCellReuseID @"ASThrashTestCellReuseID" +#endif + +static NSString *ASThrashArrayDescription(NSArray *array) { + NSMutableString *str = [NSMutableString stringWithString:@"(\n"]; + NSInteger i = 0; + for (id obj in array) { + [str appendFormat:@"\t[%ld]: \"%@\",\n", i, obj]; + i += 1; + } + [str appendString:@")"]; + return str; +} +@interface ASThrashTestItem: NSObject +#if USE_UIKIT_REFERENCE +/// This is used to identify the row with the table view (UIKit only). +@property (nonatomic, readonly) CGFloat rowHeight; +#endif +@end + +@implementation ASThrashTestItem + +- (instancetype)init { + self = [super init]; + if (self != nil) { +#if USE_UIKIT_REFERENCE + _rowHeight = arc4random_uniform(500); +#endif + } + return self; +} + ++ (NSArray *)itemsWithCount:(NSInteger)count { + NSMutableArray *result = [NSMutableArray arrayWithCapacity:count]; + for (NSInteger i = 0; i < count; i += 1) { + [result addObject:[[ASThrashTestItem alloc] init]]; + } + return result; +} + +- (NSString *)description { +#if USE_UIKIT_REFERENCE + return [NSString stringWithFormat:@"", (unsigned long)self.rowHeight]; +#else + return [NSString stringWithFormat:@"", self]; +#endif +} + +@end + +@interface ASThrashTestSection: NSObject +@property (nonatomic, strong, readonly) NSMutableArray *items; +/// This is used to identify the section with the table view. +@property (nonatomic, readonly) CGFloat headerHeight; +@end + +@implementation ASThrashTestSection + +- (instancetype)initWithCount:(NSInteger)count { + self = [super init]; + if (self != nil) { + _items = [NSMutableArray arrayWithCapacity:count]; + _headerHeight = arc4random_uniform(500) + 1; + for (NSInteger i = 0; i < count; i++) { + [_items addObject:[ASThrashTestItem new]]; + } + } + return self; +} + +- (instancetype)init { + return [self initWithCount:0]; +} + ++ (NSMutableArray *)sectionsWithCount:(NSInteger)count { + NSMutableArray *result = [NSMutableArray arrayWithCapacity:count]; + for (NSInteger i = 0; i < count; i += 1) { + [result addObject:[[ASThrashTestSection alloc] initWithCount:kInitialItemCount]]; + } + return result; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"", (unsigned long)self.headerHeight, (unsigned long)self.items.count]; +} + +@end + +#if !USE_UIKIT_REFERENCE +@interface ASThrashTestNode: ASCellNode +@property (nonatomic, strong) ASThrashTestItem *item; +@end + +@implementation ASThrashTestNode + +@end +#endif + +@interface ASThrashDataSource: NSObject +#if USE_UIKIT_REFERENCE + +#else + +#endif +@property (nonatomic, strong, readonly) NSMutableArray *data; +@end + + +@implementation ASThrashDataSource + +- (instancetype)init { + self = [super init]; + if (self != nil) { + _data = [ASThrashTestSection sectionsWithCount:kInitialSectionCount]; + } + return self; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.data[section].items.count; +} + + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.data.count; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { + return self.data[section].headerHeight; +} + +#if USE_UIKIT_REFERENCE + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + return [tableView dequeueReusableCellWithIdentifier:kCellReuseID forIndexPath:indexPath]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + ASThrashTestItem *item = self.data[indexPath.section].items[indexPath.item]; + return item.rowHeight; +} + +#else + +- (ASCellNodeBlock)tableView:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath { + ASThrashTestItem *item = self.data[indexPath.section].items[indexPath.item]; + return ^{ + ASThrashTestNode *tableNode = [[ASThrashTestNode alloc] init]; + tableNode.item = item; + return tableNode; + }; +} + +#endif + +@end + + +@implementation NSIndexSet (ASThrashHelpers) + +- (NSArray *)indexPathsInSection:(NSInteger)section { + NSMutableArray *result = [NSMutableArray arrayWithCapacity:self.count]; + [self enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { + [result addObject:[NSIndexPath indexPathForItem:idx inSection:section]]; + }]; + return result; +} + +@end + +@interface ASTableViewThrashTests: XCTestCase +@end + +@implementation ASTableViewThrashTests { + CGRect screenBounds; + ASThrashDataSource *ds; + UIWindow *window; +#if USE_UIKIT_REFERENCE + UITableView *tableView; +#else + ASTableNode *tableNode; + ASTableView *tableView; +#endif + + NSInteger minimumItemCount; + NSInteger minimumSectionCount; + float fickleness; +} + +- (void)setUp { + minimumItemCount = 5; + minimumSectionCount = 3; + fickleness = 0.1; + window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + ds = [[ASThrashDataSource alloc] init]; +#if USE_UIKIT_REFERENCE + tableView = [[UITableView alloc] initWithFrame:window.bounds style:UITableViewStyleGrouped]; + [window addSubview:tableView]; + tableView.dataSource = ds; + tableView.delegate = ds; + [tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kCellReuseID]; + [window layoutIfNeeded]; +#else + tableNode = [[ASTableNode alloc] initWithStyle:UITableViewStyleGrouped]; + tableNode.frame = window.bounds; + [window addSubnode:tableNode]; + tableNode.dataSource = ds; + tableNode.delegate = ds; + [tableView reloadDataImmediately]; +#endif + +} + +- (void)testInitialDataRead { + [self verifyTableStateWithHierarchy]; +} + +- (void)testThrashingWildly { + for (NSInteger i = 0; i < 100; i++) { + [self _testThrashingWildly]; + } +} + +- (void)_testThrashingWildly { + NSLog(@"Old data: %@", ASThrashArrayDescription(ds.data)); + NSMutableArray *deletedItems = [NSMutableArray array]; + NSMutableArray *replacedItems = [NSMutableArray array]; + NSMutableArray *insertedItems = [NSMutableArray array]; + NSInteger i = 0; + + // Randomly reload some items + for (ASThrashTestSection *section in ds.data) { + NSMutableIndexSet *indexes = [self randomIndexesLessThan:section.items.count probability:fickleness insertMode:NO]; + NSArray *newItems = [ASThrashTestItem itemsWithCount:indexes.count]; + [section.items replaceObjectsAtIndexes:indexes withObjects:newItems]; + [replacedItems addObject:indexes]; + i += 1; + } + + // Randomly replace some sections + NSMutableIndexSet *replacedSections = [self randomIndexesLessThan:ds.data.count probability:fickleness insertMode:NO]; + NSArray *replacingSections = [ASThrashTestSection sectionsWithCount:replacedSections.count]; + [ds.data replaceObjectsAtIndexes:replacedSections withObjects:replacingSections]; + + // Randomly delete some items + i = 0; + for (ASThrashTestSection *section in ds.data) { + if (section.items.count >= minimumItemCount) { + NSMutableIndexSet *indexes = [self randomIndexesLessThan:section.items.count probability:fickleness insertMode:NO]; + + /// Cannot reload & delete the same item. + [indexes removeIndexes:replacedItems[i]]; + + [section.items removeObjectsAtIndexes:indexes]; + [deletedItems addObject:indexes]; + } else { + [deletedItems addObject:[NSMutableIndexSet indexSet]]; + } + i += 1; + } + + // Randomly delete some sections + NSMutableIndexSet *deletedSections = nil; + if (ds.data.count >= minimumSectionCount) { + deletedSections = [self randomIndexesLessThan:ds.data.count probability:fickleness insertMode:NO]; + + // Cannot reload & delete the same section. + [deletedSections removeIndexes:replacedSections]; + } else { + deletedSections = [NSMutableIndexSet indexSet]; + } + [ds.data removeObjectsAtIndexes:deletedSections]; + + // Randomly insert some sections + NSMutableIndexSet *insertedSections = [self randomIndexesLessThan:(ds.data.count + 1) probability:fickleness insertMode:YES]; + NSArray *newSections = [ASThrashTestSection sectionsWithCount:insertedSections.count]; + [ds.data insertObjects:newSections atIndexes:insertedSections]; + + // Randomly insert some items + i = 0; + for (ASThrashTestSection *section in ds.data) { + NSMutableIndexSet *indexes = [self randomIndexesLessThan:(section.items.count + 1) probability:fickleness insertMode:YES]; + NSArray *newItems = [ASThrashTestItem itemsWithCount:indexes.count]; + [section.items insertObjects:newItems atIndexes:indexes]; + [insertedItems addObject:indexes]; + i += 1; + } + + NSLog(@"Deleted items: %@\nDeleted sections: %@\nReplaced items: %@\nReplaced sections: %@\nInserted items: %@\nInserted sections: %@\nNew data: %@", ASThrashArrayDescription(deletedItems), deletedSections, ASThrashArrayDescription(replacedItems), replacedSections, ASThrashArrayDescription(insertedItems), insertedSections, ASThrashArrayDescription(ds.data)); + + // TODO: Submit changes in random order, randomly chunked up + + [tableView beginUpdates]; + i = 0; + for (NSIndexSet *indexes in insertedItems) { + NSArray *indexPaths = [indexes indexPathsInSection:i]; + NSLog(@"Requested to insert rows: %@", indexPaths); + [tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; + i += 1; + } + + [tableView insertSections:insertedSections withRowAnimation:UITableViewRowAnimationNone]; + + [tableView deleteSections:deletedSections withRowAnimation:UITableViewRowAnimationNone]; + + i = 0; + for (NSIndexSet *indexes in deletedItems) { + NSArray *indexPaths = [indexes indexPathsInSection:i]; + NSLog(@"Requested to delete rows: %@", indexPaths); + [tableView deleteRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; + i += 1; + } + + i = 0; + for (NSIndexSet *indexes in replacedItems) { + NSArray *indexPaths = [indexes indexPathsInSection:i]; + NSLog(@"Requested to reload rows: %@", indexPaths); + [tableView reloadRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; + i += 1; + } + + [tableView endUpdates]; +#if !USE_UIKIT_REFERENCE + [tableView waitUntilAllUpdatesAreCommitted]; +#endif + [self verifyTableStateWithHierarchy]; +} + +/// `insertMode` means that for each index selected, the max goes up by one. +- (NSMutableIndexSet *)randomIndexesLessThan:(NSInteger)max probability:(float)probability insertMode:(BOOL)insertMode { + NSMutableIndexSet *indexes = [[NSMutableIndexSet alloc] init]; + u_int32_t cutoff = probability * 100; + for (NSInteger i = 0; i < max; i++) { + if (arc4random_uniform(100) < cutoff) { + [indexes addIndex:i]; + if (insertMode) { + max += 1; + } + } + } + return indexes; +} + +#pragma mark Helpers + +- (void)verifyTableStateWithHierarchy { + NSArray *data = [ds data]; + XCTAssertEqual(data.count, tableView.numberOfSections); + for (NSInteger i = 0; i < tableView.numberOfSections; i++) { + XCTAssertEqual([tableView numberOfRowsInSection:i], data[i].items.count); + XCTAssertEqual([tableView rectForHeaderInSection:i].size.height, data[i].headerHeight); + + for (NSInteger j = 0; j < [tableView numberOfRowsInSection:i]; j++) { + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:j inSection:i]; + ASThrashTestItem *item = data[i].items[j]; +#if USE_UIKIT_REFERENCE + XCTAssertEqual([tableView rectForRowAtIndexPath:indexPath].size.height, item.rowHeight); +#else + ASThrashTestNode *node = (ASThrashTestNode *)[tableView nodeForRowAtIndexPath:indexPath]; + XCTAssertEqual(node.item, item); +#endif + } + } +} + +@end