diff --git a/AsyncDisplayKit.xcodeproj/project.pbxproj b/AsyncDisplayKit.xcodeproj/project.pbxproj index ecc2761f62..5879b52280 100644 --- a/AsyncDisplayKit.xcodeproj/project.pbxproj +++ b/AsyncDisplayKit.xcodeproj/project.pbxproj @@ -190,6 +190,8 @@ 92DD2FE61BF4D05E0074C9DD /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92DD2FE51BF4D05E0074C9DD /* MapKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 92DD2FE71BF4D0850074C9DD /* ASMapNode.mm in Sources */ = {isa = PBXBuildFile; fileRef = 92DD2FE21BF4B97E0074C9DD /* ASMapNode.mm */; }; 92DD2FE81BF4D0A80074C9DD /* ASMapNode.h in Headers */ = {isa = PBXBuildFile; fileRef = 92DD2FE11BF4B97E0074C9DD /* ASMapNode.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9644CFE02193777C00213478 /* ASThrashUtility.m in Sources */ = {isa = PBXBuildFile; fileRef = 9644CFDF2193777C00213478 /* ASThrashUtility.m */; }; + 9692B4FF219E12370060C2C3 /* ASCollectionViewThrashTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9692B4FE219E12370060C2C3 /* ASCollectionViewThrashTests.mm */; }; 9C49C3701B853961000B0DD5 /* ASStackLayoutElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 9C49C36E1B853957000B0DD5 /* ASStackLayoutElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; 9C55866B1BD54A1900B50E3A /* ASAsciiArtBoxCreator.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9C5586681BD549CB00B50E3A /* ASAsciiArtBoxCreator.mm */; }; 9C55866C1BD54A3000B50E3A /* ASAsciiArtBoxCreator.h in Headers */ = {isa = PBXBuildFile; fileRef = 9C5586671BD549CB00B50E3A /* ASAsciiArtBoxCreator.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -759,6 +761,9 @@ 92DD2FE11BF4B97E0074C9DD /* ASMapNode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASMapNode.h; sourceTree = ""; }; 92DD2FE21BF4B97E0074C9DD /* ASMapNode.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ASMapNode.mm; sourceTree = ""; }; 92DD2FE51BF4D05E0074C9DD /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; + 9644CFDE2193777C00213478 /* ASThrashUtility.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ASThrashUtility.h; sourceTree = ""; }; + 9644CFDF2193777C00213478 /* ASThrashUtility.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ASThrashUtility.m; sourceTree = ""; }; + 9692B4FE219E12370060C2C3 /* ASCollectionViewThrashTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ASCollectionViewThrashTests.mm; sourceTree = ""; }; 9C49C36E1B853957000B0DD5 /* ASStackLayoutElement.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASStackLayoutElement.h; sourceTree = ""; }; 9C5586671BD549CB00B50E3A /* ASAsciiArtBoxCreator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASAsciiArtBoxCreator.h; sourceTree = ""; }; 9C5586681BD549CB00B50E3A /* ASAsciiArtBoxCreator.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ASAsciiArtBoxCreator.mm; sourceTree = ""; }; @@ -1284,6 +1289,7 @@ 058D09C5195D04C000B7D73C /* Tests */ = { isa = PBXGroup; children = ( + 9692B4FE219E12370060C2C3 /* ASCollectionViewThrashTests.mm */, F325E48F217460B000AC93A4 /* ASTextNode2Tests.mm */, F325E48B21745F9E00AC93A4 /* ASButtonNodeTests.mm */, F3F698D1211CAD4600800CB1 /* ASDisplayViewAccessibilityTests.mm */, @@ -1355,6 +1361,8 @@ 81E95C131D62639600336598 /* ASTextNodeSnapshotTests.mm */, 058D0A36195D057000B7D73C /* ASTextNodeTests.mm */, 058D0A37195D057000B7D73C /* ASTextNodeWordKernerTests.mm */, + 9644CFDE2193777C00213478 /* ASThrashUtility.h */, + 9644CFDF2193777C00213478 /* ASThrashUtility.m */, CCE4F9BC1F0ECE5200062E4E /* ASTLayoutFixture.h */, CCE4F9BD1F0ECE5200062E4E /* ASTLayoutFixture.mm */, CC0AEEA31D66316E005D1C78 /* ASUICollectionViewTests.mm */, @@ -2287,6 +2295,7 @@ CCB2F34D1D63CCC6004E6DE9 /* ASDisplayNodeSnapshotTests.mm in Sources */, AE6987C11DD04E1000B9E458 /* ASPagerNodeTests.mm in Sources */, 058D0A3A195D057000B7D73C /* ASDisplayNodeTests.mm in Sources */, + 9644CFE02193777C00213478 /* ASThrashUtility.m in Sources */, 696FCB311D6E46050093471E /* ASBackgroundLayoutSpecSnapshotTests.mm in Sources */, CC583AD81EF9BDC300134156 /* OCMockObject+ASAdditions.mm in Sources */, 69FEE53D1D95A9AF0086F066 /* ASLayoutElementStyleTests.mm in Sources */, @@ -2305,6 +2314,7 @@ 052EE0661A159FEF002C6279 /* ASMultiplexImageNodeTests.mm in Sources */, 058D0A3C195D057000B7D73C /* ASMutableAttributedStringBuilderTests.mm in Sources */, F325E48C21745F9E00AC93A4 /* ASButtonNodeTests.mm in Sources */, + 9692B4FF219E12370060C2C3 /* ASCollectionViewThrashTests.mm in Sources */, E586F96C1F9F9E2900ECE00E /* ASScrollNodeTests.mm in Sources */, CC8B05D81D73979700F54286 /* ASTextNodePerformanceTests.mm in Sources */, CC583AD91EF9BDC600134156 /* ASDisplayNode+OCMock.mm in Sources */, diff --git a/Tests/ASCollectionViewThrashTests.mm b/Tests/ASCollectionViewThrashTests.mm new file mode 100644 index 0000000000..4650639d1f --- /dev/null +++ b/Tests/ASCollectionViewThrashTests.mm @@ -0,0 +1,217 @@ +// +// ASCollectionViewThrashTests.mm +// Texture +// +// Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. +// Changes after 4/13/2017 are: Copyright (c) Pinterest, Inc. All rights reserved. +// Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 +// + +#import +#import +#import +#import + +#import "ASThrashUtility.h" + +@interface ASCollectionViewThrashTests : XCTestCase + +@end + +@implementation ASCollectionViewThrashTests +{ + // The current update, which will be logged in case of a failure. + ASThrashUpdate *_update; + BOOL _failed; +} + +- (void)tearDown +{ + if (_failed && _update != nil) { + NSLog(@"Failed update %@: %@", _update, _update.logFriendlyBase64Representation); + } + _failed = NO; + _update = nil; +} + +// NOTE: Despite the documentation, this is not always called if an exception is caught. +- (void)recordFailureWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber expected:(BOOL)expected +{ + _failed = YES; + [super recordFailureWithDescription:description inFile:filePath atLine:lineNumber expected:expected]; +} + +- (void)verifyDataSource:(ASThrashDataSource *)ds +{ + CollectionView *collectionView = ds.collectionView; + NSArray *data = [ds data]; + for (NSInteger i = 0; i < collectionView.numberOfSections; i++) { + XCTAssertEqual([collectionView numberOfItemsInSection:i], data[i].items.count); + + for (NSInteger j = 0; j < [collectionView numberOfItemsInSection:i]; j++) { + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:j inSection:i]; + ASThrashTestItem *item = data[i].items[j]; + ASThrashTestNode *node = (ASThrashTestNode *)[collectionView nodeForItemAtIndexPath:indexPath]; + XCTAssertEqualObjects(node.item, item, @"Wrong node at index path %@", indexPath); + } + } +} + +#pragma mark Test Methods + +- (void)testInitialDataRead +{ + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initCollectionViewDataSourceWithData:[ASThrashTestSection sectionsWithCount:kInitialSectionCount]]; + [self verifyDataSource:ds]; +} + +/// Replays the Base64 representation of an ASThrashUpdate from "ASThrashTestRecordedCase" file +- (void)testRecordedThrashCase +{ + NSURL *caseURL = [[NSBundle bundleForClass:[self class]] URLForResource:@"ASThrashTestRecordedCase" withExtension:nil subdirectory:@"TestResources"]; + NSString *base64 = [NSString stringWithContentsOfURL:caseURL encoding:NSUTF8StringEncoding error:NULL]; + + _update = [ASThrashUpdate thrashUpdateWithBase64String:base64]; + if (_update == nil) { + return; + } + + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initCollectionViewDataSourceWithData:_update.oldData]; + [self applyUpdateUsingBatchUpdates:_update + toDataSource:ds + animated:NO + useXCTestWait:YES]; + [self verifyDataSource:ds]; +} + +- (void)testThrashingWildly +{ + for (NSInteger i = 0; i < kThrashingIterationCount; i++) { + [self setUp]; + @autoreleasepool { + NSArray *sections = [ASThrashTestSection sectionsWithCount:kInitialSectionCount]; + _update = [[ASThrashUpdate alloc] initWithData:sections]; + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initCollectionViewDataSourceWithData:sections]; + + [self applyUpdateUsingBatchUpdates:_update + toDataSource:ds + animated:NO + useXCTestWait:NO]; + [self verifyDataSource:ds]; + [self expectationForPredicate:[ds predicateForDeallocatedHierarchy] evaluatedWithObject:(id)kCFNull handler:nil]; + } + [self waitForExpectationsWithTimeout:3 handler:nil]; + + [self tearDown]; + } +} + +- (void)testThrashingWildlyOnSameCollectionView +{ + XCTestExpectation *expectation = [self expectationWithDescription:@"last test ran"]; + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initCollectionViewDataSourceWithData:nil]; + for (NSInteger i = 0; i < 1000; i++) { + [self setUp]; + @autoreleasepool { + NSArray *sections = [ASThrashTestSection sectionsWithCount:kInitialSectionCount]; + _update = [[ASThrashUpdate alloc] initWithData:sections]; + [ds setData:sections]; + [ds.collectionView reloadData]; + + [self applyUpdateUsingBatchUpdates:_update + toDataSource:ds + animated:NO + useXCTestWait:NO]; + [self verifyDataSource:ds]; + if (i == 999) { + [expectation fulfill]; + } + } + + [self tearDown]; + } + [self waitForExpectationsWithTimeout:3 handler:nil]; +} + +- (void)testThrashingWildlyDispatchWildly +{ + XCTestExpectation *expectation = [self expectationWithDescription:@"last test ran"]; + for (NSInteger i = 0; i < kThrashingIterationCount; i++) { + [self setUp]; + @autoreleasepool { + dispatch_async(dispatch_get_main_queue(), ^{ + NSArray *sections = [ASThrashTestSection sectionsWithCount:kInitialSectionCount]; + _update = [[ASThrashUpdate alloc] initWithData:sections]; + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initCollectionViewDataSourceWithData:sections]; + + [self applyUpdateUsingBatchUpdates:_update + toDataSource:ds + animated:NO + useXCTestWait:NO]; + [self verifyDataSource:ds]; + if (i == kThrashingIterationCount-1) { + [expectation fulfill]; + } + }); + } + + [self tearDown]; + } + + [self waitForExpectationsWithTimeout:100 handler:nil]; +} + +#pragma mark Helpers + +- (void)applyUpdateUsingBatchUpdates:(ASThrashUpdate *)update + toDataSource:(ASThrashDataSource *)dataSource animated:(BOOL)animated + useXCTestWait:(BOOL)wait +{ + CollectionView *collectionView = dataSource.collectionView; + + XCTestExpectation *expectation; + if (wait) { + expectation = [self expectationWithDescription:@"Wait for collection view to update"]; + } + + void (^updateBlock)() = ^ void (){ + dataSource.data = update.data; + + [collectionView insertSections:update.insertedSectionIndexes]; + [collectionView deleteSections:update.deletedSectionIndexes]; + [collectionView reloadSections:update.replacedSectionIndexes]; + + [update.insertedItemIndexes enumerateObjectsUsingBlock:^(NSMutableIndexSet * _Nonnull indexes, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *indexPaths = [indexes indexPathsInSection:idx]; + [collectionView insertItemsAtIndexPaths:indexPaths]; + }]; + + [update.deletedItemIndexes enumerateObjectsUsingBlock:^(NSMutableIndexSet * _Nonnull indexes, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *indexPaths = [indexes indexPathsInSection:idx]; + [collectionView deleteItemsAtIndexPaths:indexPaths]; + }]; + + [update.replacedItemIndexes enumerateObjectsUsingBlock:^(NSMutableIndexSet * _Nonnull indexes, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *indexPaths = [indexes indexPathsInSection:idx]; + [collectionView reloadItemsAtIndexPaths:indexPaths]; + }]; + }; + + @try { + [collectionView performBatchAnimated:animated + updates:updateBlock + completion:^(BOOL finished) { + [expectation fulfill]; + }]; + } @catch (NSException *exception) { + _failed = YES; + XCTFail("TEST FAILED"); + @throw exception; + } + + if (wait) { + [self waitForExpectationsWithTimeout:1 handler:nil]; + } +} + +@end diff --git a/Tests/ASTableViewThrashTests.mm b/Tests/ASTableViewThrashTests.mm index dbf94ba2be..8d7064cd00 100644 --- a/Tests/ASTableViewThrashTests.mm +++ b/Tests/ASTableViewThrashTests.mm @@ -13,461 +13,13 @@ #import #import - -// Set to 1 to use UITableView and see if the issue still exists. -#define USE_UIKIT_REFERENCE 0 - -#if USE_UIKIT_REFERENCE -#define TableView UITableView -#define kCellReuseID @"ASThrashTestCellReuseID" -#else -#define TableView ASTableView -#endif - -#define kInitialSectionCount 10 -#define kInitialItemCount 10 -#define kMinimumItemCount 5 -#define kMinimumSectionCount 3 -#define kFickleness 0.1 -#define kThrashingIterationCount 100 - -static NSString *ASThrashArrayDescription(NSArray *array) { - NSMutableString *str = [NSMutableString stringWithString:@"(\n"]; - NSInteger i = 0; - for (id obj in array) { - [str appendFormat:@"\t[%ld]: \"%@\",\n", (long)i, obj]; - i += 1; - } - [str appendString:@")"]; - return str; -} - -static atomic_uint ASThrashTestItemNextID; -@interface ASThrashTestItem: NSObject -@property (nonatomic, readonly) NSInteger itemID; - -- (CGFloat)rowHeight; -@end - -@implementation ASThrashTestItem - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (instancetype)init { - self = [super init]; - if (self != nil) { - _itemID = atomic_fetch_add(&ASThrashTestItemNextID, 1); - } - return self; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - self = [super init]; - if (self != nil) { - _itemID = [aDecoder decodeIntegerForKey:@"itemID"]; - NSAssert(_itemID > 0, @"Failed to decode %@", self); - } - return self; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInteger:_itemID forKey:@"itemID"]; -} - -+ (NSMutableArray *)itemsWithCount:(NSInteger)count { - NSMutableArray *result = [NSMutableArray arrayWithCapacity:count]; - for (NSInteger i = 0; i < count; i += 1) { - [result addObject:[[ASThrashTestItem alloc] init]]; - } - return result; -} - -- (CGFloat)rowHeight { - return (self.itemID % 400) ?: 44; -} - -- (NSString *)description { - return [NSString stringWithFormat:@"", (unsigned long)_itemID]; -} - -@end - -@interface ASThrashTestSection: NSObject -@property (nonatomic, readonly) NSMutableArray *items; -@property (nonatomic, readonly) NSInteger sectionID; - -- (CGFloat)headerHeight; -@end - -static atomic_uint ASThrashTestSectionNextID = 1; -@implementation ASThrashTestSection - -/// Create an array of sections with the given count -+ (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; -} - -- (instancetype)initWithCount:(NSInteger)count { - self = [super init]; - if (self != nil) { - _sectionID = atomic_fetch_add(&ASThrashTestSectionNextID, 1); - _items = [ASThrashTestItem itemsWithCount:count]; - } - return self; -} - -- (instancetype)init { - return [self initWithCount:0]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - self = [super init]; - if (self != nil) { - _items = [aDecoder decodeObjectOfClass:[NSArray class] forKey:@"items"]; - _sectionID = [aDecoder decodeIntegerForKey:@"sectionID"]; - NSAssert(_sectionID > 0, @"Failed to decode %@", self); - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:_items forKey:@"items"]; - [aCoder encodeInteger:_sectionID forKey:@"sectionID"]; -} - -- (CGFloat)headerHeight { - return self.sectionID % 400 ?: 44; -} - -- (NSString *)description { - return [NSString stringWithFormat:@"
", (unsigned long)_sectionID, (unsigned long)self.items.count, ASThrashArrayDescription(self.items)]; -} - -- (id)copyWithZone:(NSZone *)zone { - ASThrashTestSection *copy = [[ASThrashTestSection alloc] init]; - copy->_sectionID = _sectionID; - copy->_items = [_items mutableCopy]; - return copy; -} - -- (BOOL)isEqual:(id)object { - if ([object isKindOfClass:[ASThrashTestSection class]]) { - return [(ASThrashTestSection *)object sectionID] == _sectionID; - } else { - return NO; - } -} - -@end - -#if !USE_UIKIT_REFERENCE -@interface ASThrashTestNode: ASCellNode -@property (nonatomic) ASThrashTestItem *item; -@end - -@implementation ASThrashTestNode - -- (CGSize)calculateSizeThatFits:(CGSize)constrainedSize -{ - ASDisplayNodeAssertFalse(isinf(constrainedSize.width)); - return CGSizeMake(constrainedSize.width, 44); -} - -@end -#endif - -@interface ASThrashDataSource: NSObject -#if USE_UIKIT_REFERENCE - -#else - -#endif - -@property (nonatomic, readonly) UIWindow *window; -@property (nonatomic, readonly) TableView *tableView; -@property (nonatomic) NSArray *data; -// Only access on main -@property (nonatomic) ASWeakSet *allNodes; -@end - - -@implementation ASThrashDataSource - -- (instancetype)initWithData:(NSArray *)data { - self = [super init]; - if (self != nil) { - _data = [[NSArray alloc] initWithArray:data copyItems:YES]; - _window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; - _tableView = [[TableView alloc] initWithFrame:_window.bounds style:UITableViewStylePlain]; - _allNodes = [[ASWeakSet alloc] init]; - [_window addSubview:_tableView]; -#if USE_UIKIT_REFERENCE - _tableView.dataSource = self; - _tableView.delegate = self; - [_tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kCellReuseID]; -#else - _tableView.asyncDelegate = self; - _tableView.asyncDataSource = self; - [_tableView reloadData]; - [_tableView waitUntilAllUpdatesAreCommitted]; -#endif - [_tableView layoutIfNeeded]; - } - 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; -} - -/// Object passed into predicate is ignored. -- (NSPredicate *)predicateForDeallocatedHierarchy -{ - ASWeakSet *allNodes = self.allNodes; - __weak UIWindow *window = _window; - __weak ASTableView *view = _tableView; - return [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary * _Nullable bindings) { - return window == nil && view == nil && allNodes.isEmpty; - }]; -} - -#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 - -- (ASCellNode *)tableView:(ASTableView *)tableView nodeForRowAtIndexPath:(NSIndexPath *)indexPath -{ - ASThrashTestNode *node = [[ASThrashTestNode alloc] init]; - node.item = self.data[indexPath.section].items[indexPath.item]; - [self.allNodes addObject:node]; - return node; -} - -#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; -} - -/// `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; -} - -@end - -static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; - -@interface ASThrashUpdate : NSObject -@property (nonatomic, readonly) NSArray *oldData; -@property (nonatomic, readonly) NSMutableArray *data; -@property (nonatomic, readonly) NSMutableIndexSet *deletedSectionIndexes; -@property (nonatomic, readonly) NSMutableIndexSet *replacedSectionIndexes; -/// The sections used to replace the replaced sections. -@property (nonatomic, readonly) NSMutableArray *replacingSections; -@property (nonatomic, readonly) NSMutableIndexSet *insertedSectionIndexes; -@property (nonatomic, readonly) NSMutableArray *insertedSections; -@property (nonatomic, readonly) NSMutableArray *deletedItemIndexes; -@property (nonatomic, readonly) NSMutableArray *replacedItemIndexes; -/// The items used to replace the replaced items. -@property (nonatomic, readonly) NSMutableArray *> *replacingItems; -@property (nonatomic, readonly) NSMutableArray *insertedItemIndexes; -@property (nonatomic, readonly) NSMutableArray *> *insertedItems; - -- (instancetype)initWithData:(NSArray *)data; - -+ (ASThrashUpdate *)thrashUpdateWithBase64String:(NSString *)base64; -- (NSString *)base64Representation; -@end - -@implementation ASThrashUpdate - -- (instancetype)initWithData:(NSArray *)data { - self = [super init]; - if (self != nil) { - _data = [[NSMutableArray alloc] initWithArray:data copyItems:YES]; - _oldData = [[NSArray alloc] initWithArray:data copyItems:YES]; - - _deletedItemIndexes = [NSMutableArray array]; - _replacedItemIndexes = [NSMutableArray array]; - _insertedItemIndexes = [NSMutableArray array]; - _replacingItems = [NSMutableArray array]; - _insertedItems = [NSMutableArray array]; - - // Randomly reload some items - for (ASThrashTestSection *section in _data) { - NSMutableIndexSet *indexes = [NSIndexSet randomIndexesLessThan:section.items.count probability:kFickleness insertMode:NO]; - NSArray *newItems = [ASThrashTestItem itemsWithCount:indexes.count]; - [section.items replaceObjectsAtIndexes:indexes withObjects:newItems]; - [_replacingItems addObject:newItems]; - [_replacedItemIndexes addObject:indexes]; - } - - // Randomly replace some sections - _replacedSectionIndexes = [NSIndexSet randomIndexesLessThan:_data.count probability:kFickleness insertMode:NO]; - _replacingSections = [ASThrashTestSection sectionsWithCount:_replacedSectionIndexes.count]; - [_data replaceObjectsAtIndexes:_replacedSectionIndexes withObjects:_replacingSections]; - - // Randomly delete some items - [_data enumerateObjectsUsingBlock:^(ASThrashTestSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - if (section.items.count >= kMinimumItemCount) { - NSMutableIndexSet *indexes = [NSIndexSet randomIndexesLessThan:section.items.count probability:kFickleness insertMode:NO]; - - /// Cannot reload & delete the same item. - [indexes removeIndexes:_replacedItemIndexes[idx]]; - - [section.items removeObjectsAtIndexes:indexes]; - [_deletedItemIndexes addObject:indexes]; - } else { - [_deletedItemIndexes addObject:[NSMutableIndexSet indexSet]]; - } - }]; - - // Randomly delete some sections - if (_data.count >= kMinimumSectionCount) { - _deletedSectionIndexes = [NSIndexSet randomIndexesLessThan:_data.count probability:kFickleness insertMode:NO]; - } else { - _deletedSectionIndexes = [NSMutableIndexSet indexSet]; - } - // Cannot replace & delete the same section. - [_deletedSectionIndexes removeIndexes:_replacedSectionIndexes]; - - // Cannot delete/replace item in deleted/replaced section - [_deletedSectionIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { - [_replacedItemIndexes[idx] removeAllIndexes]; - [_deletedItemIndexes[idx] removeAllIndexes]; - }]; - [_replacedSectionIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { - [_replacedItemIndexes[idx] removeAllIndexes]; - [_deletedItemIndexes[idx] removeAllIndexes]; - }]; - [_data removeObjectsAtIndexes:_deletedSectionIndexes]; - - // Randomly insert some sections - _insertedSectionIndexes = [NSIndexSet randomIndexesLessThan:(_data.count + 1) probability:kFickleness insertMode:YES]; - _insertedSections = [ASThrashTestSection sectionsWithCount:_insertedSectionIndexes.count]; - [_data insertObjects:_insertedSections atIndexes:_insertedSectionIndexes]; - - // Randomly insert some items - for (ASThrashTestSection *section in _data) { - // Only insert items into the old sections – not replaced/inserted sections. - if ([_oldData containsObject:section]) { - NSMutableIndexSet *indexes = [NSIndexSet randomIndexesLessThan:(section.items.count + 1) probability:kFickleness insertMode:YES]; - NSArray *newItems = [ASThrashTestItem itemsWithCount:indexes.count]; - [section.items insertObjects:newItems atIndexes:indexes]; - [_insertedItems addObject:newItems]; - [_insertedItemIndexes addObject:indexes]; - } else { - [_insertedItems addObject:@[]]; - [_insertedItemIndexes addObject:[NSMutableIndexSet indexSet]]; - } - } - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -+ (ASThrashUpdate *)thrashUpdateWithBase64String:(NSString *)base64 { - return [NSKeyedUnarchiver unarchiveObjectWithData:[[NSData alloc] initWithBase64EncodedString:base64 options:kNilOptions]]; -} - -- (NSString *)base64Representation { - return [[NSKeyedArchiver archivedDataWithRootObject:self] base64EncodedStringWithOptions:kNilOptions]; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - NSDictionary *dict = [self dictionaryWithValuesForKeys:@[ - @"oldData", - @"data", - @"deletedSectionIndexes", - @"replacedSectionIndexes", - @"replacingSections", - @"insertedSectionIndexes", - @"insertedSections", - @"deletedItemIndexes", - @"replacedItemIndexes", - @"replacingItems", - @"insertedItemIndexes", - @"insertedItems" - ]]; - [aCoder encodeObject:dict forKey:@"_dict"]; - [aCoder encodeInteger:ASThrashUpdateCurrentSerializationVersion forKey:@"_version"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - self = [super init]; - if (self != nil) { - NSAssert(ASThrashUpdateCurrentSerializationVersion == [aDecoder decodeIntegerForKey:@"_version"], @"This thrash update was archived from a different version and can't be read. Sorry."); - NSDictionary *dict = [aDecoder decodeObjectOfClass:[NSDictionary class] forKey:@"_dict"]; - [self setValuesForKeysWithDictionary:dict]; - } - return self; -} - -- (NSString *)description { - return [NSString stringWithFormat:@"", self, ASThrashArrayDescription(_oldData), ASThrashArrayDescription(_deletedItemIndexes), _deletedSectionIndexes, ASThrashArrayDescription(_replacedItemIndexes), _replacedSectionIndexes, ASThrashArrayDescription(_insertedItemIndexes), _insertedSectionIndexes, ASThrashArrayDescription(_data)]; -} - -- (NSString *)logFriendlyBase64Representation { - return [NSString stringWithFormat:@"\n\n**********\nBase64 Representation:\n**********\n%@\n**********\nEnd Base64 Representation\n**********", self.base64Representation]; -} - -@end +#import "ASThrashUtility.h" @interface ASTableViewThrashTests: XCTestCase @end -@implementation ASTableViewThrashTests { +@implementation ASTableViewThrashTests +{ // The current update, which will be logged in case of a failure. ASThrashUpdate *_update; BOOL _failed; @@ -475,7 +27,8 @@ static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; #pragma mark Overrides -- (void)tearDown { +- (void)tearDown +{ if (_failed && _update != nil) { NSLog(@"Failed update %@: %@", _update, _update.logFriendlyBase64Representation); } @@ -484,7 +37,8 @@ static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; } // NOTE: Despite the documentation, this is not always called if an exception is caught. -- (void)recordFailureWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber expected:(BOOL)expected { +- (void)recordFailureWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber expected:(BOOL)expected +{ _failed = YES; [super recordFailureWithDescription:description inFile:filePath atLine:lineNumber expected:expected]; } @@ -492,13 +46,15 @@ static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; #pragma mark Test Methods // Disabled temporarily due to issue where cell nodes are not marked invisible before deallocation. -- (void)DISABLED_testInitialDataRead { - ASThrashDataSource *ds = [[ASThrashDataSource alloc] initWithData:[ASThrashTestSection sectionsWithCount:kInitialSectionCount]]; +- (void)testInitialDataRead +{ + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initTableViewDataSourceWithData:[ASThrashTestSection sectionsWithCount:kInitialSectionCount]]; [self verifyDataSource:ds]; } /// Replays the Base64 representation of an ASThrashUpdate from "ASThrashTestRecordedCase" file -- (void)DISABLED_testRecordedThrashCase { +- (void)testRecordedThrashCase +{ NSURL *caseURL = [[NSBundle bundleForClass:[self class]] URLForResource:@"ASThrashTestRecordedCase" withExtension:nil subdirectory:@"TestResources"]; NSString *base64 = [NSString stringWithContentsOfURL:caseURL encoding:NSUTF8StringEncoding error:NULL]; @@ -507,20 +63,21 @@ static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; return; } - ASThrashDataSource *ds = [[ASThrashDataSource alloc] initWithData:_update.oldData]; + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initTableViewDataSourceWithData:_update.oldData]; ds.tableView.test_enableSuperUpdateCallLogging = YES; [self applyUpdate:_update toDataSource:ds]; [self verifyDataSource:ds]; } // Disabled temporarily due to issue where cell nodes are not marked invisible before deallocation. -- (void)DISABLED_testThrashingWildly { +- (void)testThrashingWildly +{ for (NSInteger i = 0; i < kThrashingIterationCount; i++) { [self setUp]; @autoreleasepool { NSArray *sections = [ASThrashTestSection sectionsWithCount:kInitialSectionCount]; _update = [[ASThrashUpdate alloc] initWithData:sections]; - ASThrashDataSource *ds = [[ASThrashDataSource alloc] initWithData:sections]; + ASThrashDataSource *ds = [[ASThrashDataSource alloc] initTableViewDataSourceWithData:sections]; [self applyUpdate:_update toDataSource:ds]; [self verifyDataSource:ds]; @@ -534,7 +91,8 @@ static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; #pragma mark Helpers -- (void)applyUpdate:(ASThrashUpdate *)update toDataSource:(ASThrashDataSource *)dataSource { +- (void)applyUpdate:(ASThrashUpdate *)update toDataSource:(ASThrashDataSource *)dataSource +{ TableView *tableView = dataSource.tableView; [tableView beginUpdates]; @@ -571,14 +129,15 @@ static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; } } -- (void)verifyDataSource:(ASThrashDataSource *)ds { +- (void)verifyDataSource:(ASThrashDataSource *)ds +{ TableView *tableView = ds.tableView; 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]; diff --git a/Tests/ASThrashUtility.h b/Tests/ASThrashUtility.h new file mode 100644 index 0000000000..09e7847750 --- /dev/null +++ b/Tests/ASThrashUtility.h @@ -0,0 +1,111 @@ +// +// Tests/ASThrashUtility.h +// Texture +// +// Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. +// Changes after 4/13/2017 are: Copyright (c) Pinterest, Inc. All rights reserved. +// Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#define kInitialSectionCount 10 +#define kInitialItemCount 10 +#define kMinimumItemCount 5 +#define kMinimumSectionCount 3 +#define kFickleness 0.1 +#define kThrashingIterationCount 10 + +// Set to 1 to use UITableView and see if the issue still exists. +#define USE_UIKIT_REFERENCE 0 + +#if USE_UIKIT_REFERENCE +#define TableView UITableView +#define CollectionView UICollectionView +#define kCellReuseID @"ASThrashTestCellReuseID" +#else +#define TableView ASTableView +#define CollectionView ASCollectionNode +#endif + +static NSInteger ASThrashUpdateCurrentSerializationVersion = 1; + +@class ASThrashTestSection; +static atomic_uint ASThrashTestItemNextID; +@interface ASThrashTestItem: NSObject +@property (nonatomic, readonly) NSInteger itemID; + ++ (NSMutableArray *)itemsWithCount:(NSInteger)count; + +- (CGFloat)rowHeight; +@end + + +@interface ASThrashTestSection: NSObject +@property (nonatomic, readonly) NSMutableArray *items; +@property (nonatomic, readonly) NSInteger sectionID; + ++ (NSMutableArray *)sectionsWithCount:(NSInteger)count; + +- (instancetype)initWithCount:(NSInteger)count; +- (CGFloat)headerHeight; +@end + +@interface ASThrashDataSource: NSObject +#if USE_UIKIT_REFERENCE + +#else + +#endif + +@property (nonatomic, readonly) UIWindow *window; +@property (nonatomic, readonly) TableView *tableView; +@property (nonatomic, readonly) CollectionView *collectionView; +@property (nonatomic) NSArray *data; +// Only access on main +@property (nonatomic) ASWeakSet *allNodes; + +- (instancetype)initTableViewDataSourceWithData:(NSArray *)data; +- (instancetype)initCollectionViewDataSourceWithData:(NSArray * _Nullable)data; +- (NSPredicate *)predicateForDeallocatedHierarchy; +@end + +@interface NSIndexSet (ASThrashHelpers) +- (NSArray *)indexPathsInSection:(NSInteger)section; +/// `insertMode` means that for each index selected, the max goes up by one. ++ (NSMutableIndexSet *)randomIndexesLessThan:(NSInteger)max probability:(float)probability insertMode:(BOOL)insertMode; +@end + +#if !USE_UIKIT_REFERENCE +@interface ASThrashTestNode: ASCellNode +@property (nonatomic) ASThrashTestItem *item; +@end +#endif + +@interface ASThrashUpdate : NSObject +@property (nonatomic, readonly) NSArray *oldData; +@property (nonatomic, readonly) NSMutableArray *data; +@property (nonatomic, readonly) NSMutableIndexSet *deletedSectionIndexes; +@property (nonatomic, readonly) NSMutableIndexSet *replacedSectionIndexes; +/// The sections used to replace the replaced sections. +@property (nonatomic, readonly) NSMutableArray *replacingSections; +@property (nonatomic, readonly) NSMutableIndexSet *insertedSectionIndexes; +@property (nonatomic, readonly) NSMutableArray *insertedSections; +@property (nonatomic, readonly) NSMutableArray *deletedItemIndexes; +@property (nonatomic, readonly) NSMutableArray *replacedItemIndexes; +/// The items used to replace the replaced items. +@property (nonatomic, readonly) NSMutableArray *> *replacingItems; +@property (nonatomic, readonly) NSMutableArray *insertedItemIndexes; +@property (nonatomic, readonly) NSMutableArray *> *insertedItems; + +- (instancetype)initWithData:(NSArray *)data; + ++ (ASThrashUpdate *)thrashUpdateWithBase64String:(NSString *)base64; +- (NSString *)base64Representation; +- (NSString *)logFriendlyBase64Representation; +@end + +NS_ASSUME_NONNULL_END diff --git a/Tests/ASThrashUtility.m b/Tests/ASThrashUtility.m new file mode 100644 index 0000000000..c555d3f776 --- /dev/null +++ b/Tests/ASThrashUtility.m @@ -0,0 +1,467 @@ +// +// ASTableViewThrashTests.mm +// Texture +// +// Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. +// Changes after 4/13/2017 are: Copyright (c) Pinterest, Inc. All rights reserved. +// Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 +// + +#import "ASThrashUtility.h" +#import +#import + +static NSString *ASThrashArrayDescription(NSArray *array) +{ + NSMutableString *str = [NSMutableString stringWithString:@"(\n"]; + NSInteger i = 0; + for (id obj in array) { + [str appendFormat:@"\t[%ld]: \"%@\",\n", (long)i, obj]; + i += 1; + } + [str appendString:@")"]; + return str; +} + +@implementation ASThrashTestItem + ++ (BOOL)supportsSecureCoding +{ + return YES; +} + +- (instancetype)init +{ + self = [super init]; + if (self != nil) { + _itemID = atomic_fetch_add(&ASThrashTestItemNextID, 1); + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super init]; + if (self != nil) { + _itemID = [aDecoder decodeIntegerForKey:@"itemID"]; + NSAssert(_itemID > 0, @"Failed to decode %@", self); + } + return self; +} + +- (void)encodeWithCoder:(NSCoder *)aCoder +{ + [aCoder encodeInteger:_itemID forKey:@"itemID"]; +} + ++ (NSMutableArray *)itemsWithCount:(NSInteger)count +{ + NSMutableArray *result = [NSMutableArray arrayWithCapacity:count]; + for (NSInteger i = 0; i < count; i += 1) { + [result addObject:[[ASThrashTestItem alloc] init]]; + } + return result; +} + +- (CGFloat)rowHeight +{ + return (self.itemID % 400) ?: 44; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"", (unsigned long)_itemID]; +} + +@end + +static atomic_uint ASThrashTestSectionNextID = 1; +@implementation ASThrashTestSection + +/// Create an array of sections with the given count ++ (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; +} + +- (instancetype)initWithCount:(NSInteger)count +{ + self = [super init]; + if (self != nil) { + _sectionID = atomic_fetch_add(&ASThrashTestSectionNextID, 1); + _items = [ASThrashTestItem itemsWithCount:count]; + } + return self; +} + +- (instancetype)init +{ + return [self initWithCount:0]; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super init]; + if (self != nil) { + _items = [aDecoder decodeObjectOfClass:[NSArray class] forKey:@"items"]; + _sectionID = [aDecoder decodeIntegerForKey:@"sectionID"]; + NSAssert(_sectionID > 0, @"Failed to decode %@", self); + } + return self; +} + ++ (BOOL)supportsSecureCoding +{ + return YES; +} + +- (void)encodeWithCoder:(NSCoder *)aCoder +{ + [aCoder encodeObject:_items forKey:@"items"]; + [aCoder encodeInteger:_sectionID forKey:@"sectionID"]; +} + +- (CGFloat)headerHeight +{ + return self.sectionID % 400 ?: 44; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"
", (unsigned long)_sectionID, (unsigned long)self.items.count, ASThrashArrayDescription(self.items)]; +} + +- (id)copyWithZone:(NSZone *)zone +{ + ASThrashTestSection *copy = [[ASThrashTestSection alloc] init]; + copy->_sectionID = _sectionID; + copy->_items = [_items mutableCopy]; + return copy; +} + +- (BOOL)isEqual:(id)object +{ + if ([object isKindOfClass:[ASThrashTestSection class]]) { + return [(ASThrashTestSection *)object sectionID] == _sectionID; + } else { + return NO; + } +} + +@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; +} + +/// `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; +} + +@end + +@implementation ASThrashDataSource + +- (instancetype)initTableViewDataSourceWithData:(NSArray *)data +{ + self = [super init]; + if (self != nil) { + _data = [[NSArray alloc] initWithArray:data copyItems:YES]; + _window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + _tableView = [[TableView alloc] initWithFrame:_window.bounds style:UITableViewStylePlain]; + _allNodes = [[ASWeakSet alloc] init]; + [_window addSubview:_tableView]; +#if USE_UIKIT_REFERENCE + _tableView.dataSource = self; + _tableView.delegate = self; + [_tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kCellReuseID]; +#else + _tableView.asyncDelegate = self; + _tableView.asyncDataSource = self; + [_tableView reloadData]; + [_tableView waitUntilAllUpdatesAreCommitted]; +#endif + [_tableView layoutIfNeeded]; + } + return self; +} + +- (instancetype)initCollectionViewDataSourceWithData:(NSArray *)data +{ + self = [super init]; + if (self != nil) { + _data = data != nil ? [[NSArray alloc] initWithArray:data copyItems:YES] : [[NSArray alloc] init]; + _window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + _collectionView = [[CollectionView alloc] initWithCollectionViewLayout:[[UICollectionViewFlowLayout alloc] init]]; + _allNodes = [[ASWeakSet alloc] init]; + [_window addSubview:_tableView]; + _collectionView.delegate = self; + _collectionView.dataSource = self; +#if USE_UIKIT_REFERENCE + [_collectionView registerClass:[UITableViewCell class] forCellReuseIdentifier:kCellReuseID]; +#else + [_collectionView reloadData]; + [_collectionView waitUntilAllUpdatesAreProcessed]; +#endif + [_collectionView layoutIfNeeded]; + } + return self; +} + +- (void)setData:(NSArray *)data +{ + _data = data; +} + +- (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; +} + +- (NSInteger)collectionNode:(ASCollectionNode *)collectionNode numberOfItemsInSection:(NSInteger)section +{ + return self.data[section].items.count; +} + + +- (NSInteger)numberOfSectionsInCollectionNode:(ASCollectionNode *)collectionNode +{ + return self.data.count; +} + +/// Object passed into predicate is ignored. +- (NSPredicate *)predicateForDeallocatedHierarchy +{ + ASWeakSet *allNodes = self.allNodes; + __weak UIWindow *window = _window; + __weak ASTableView *view = _tableView; + return [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary * _Nullable bindings) { + return window == nil && view == nil && allNodes.isEmpty; + }]; +} + +#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 + +- (ASCellNode *)collectionNode:(ASCollectionNode *)collectionNode nodeForItemAtIndexPath:(NSIndexPath *)indexPath +{ + ASThrashTestNode *node = [[ASThrashTestNode alloc] init]; + node.item = self.data[indexPath.section].items[indexPath.row]; + [self.allNodes addObject:node]; + return node; +} + +- (ASCellNode *)tableView:(ASTableView *)tableView nodeForRowAtIndexPath:(NSIndexPath *)indexPath +{ + ASThrashTestNode *node = [[ASThrashTestNode alloc] init]; + node.item = self.data[indexPath.section].items[indexPath.item]; + [self.allNodes addObject:node]; + return node; +} + +#endif + +@end + +#if !USE_UIKIT_REFERENCE +@implementation ASThrashTestNode + +- (CGSize)calculateSizeThatFits:(CGSize)constrainedSize +{ + ASDisplayNodeAssertFalse(isinf(constrainedSize.width)); + return CGSizeMake(constrainedSize.width, 44); +} + +@end +#endif + +@implementation ASThrashUpdate + +- (instancetype)initWithData:(NSArray *)data +{ + self = [super init]; + if (self != nil) { + _data = [[NSMutableArray alloc] initWithArray:data copyItems:YES]; + _oldData = [[NSArray alloc] initWithArray:data copyItems:YES]; + + _deletedItemIndexes = [NSMutableArray array]; + _replacedItemIndexes = [NSMutableArray array]; + _insertedItemIndexes = [NSMutableArray array]; + _replacingItems = [NSMutableArray array]; + _insertedItems = [NSMutableArray array]; + + // Randomly reload some items + for (ASThrashTestSection *section in _data) { + NSMutableIndexSet *indexes = [NSIndexSet randomIndexesLessThan:section.items.count probability:kFickleness insertMode:NO]; + NSArray *newItems = [ASThrashTestItem itemsWithCount:indexes.count]; + [section.items replaceObjectsAtIndexes:indexes withObjects:newItems]; + [_replacingItems addObject:newItems]; + [_replacedItemIndexes addObject:indexes]; + } + + // Randomly replace some sections + _replacedSectionIndexes = [NSIndexSet randomIndexesLessThan:_data.count probability:kFickleness insertMode:NO]; + _replacingSections = [ASThrashTestSection sectionsWithCount:_replacedSectionIndexes.count]; + [_data replaceObjectsAtIndexes:_replacedSectionIndexes withObjects:_replacingSections]; + + // Randomly delete some items + [_data enumerateObjectsUsingBlock:^(ASThrashTestSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { + if (section.items.count >= kMinimumItemCount) { + NSMutableIndexSet *indexes = [NSIndexSet randomIndexesLessThan:section.items.count probability:kFickleness insertMode:NO]; + + /// Cannot reload & delete the same item. + [indexes removeIndexes:_replacedItemIndexes[idx]]; + + [section.items removeObjectsAtIndexes:indexes]; + [_deletedItemIndexes addObject:indexes]; + } else { + [_deletedItemIndexes addObject:[NSMutableIndexSet indexSet]]; + } + }]; + + // Randomly delete some sections + if (_data.count >= kMinimumSectionCount) { + _deletedSectionIndexes = [NSIndexSet randomIndexesLessThan:_data.count probability:kFickleness insertMode:NO]; + } else { + _deletedSectionIndexes = [NSMutableIndexSet indexSet]; + } + // Cannot replace & delete the same section. + [_deletedSectionIndexes removeIndexes:_replacedSectionIndexes]; + + // Cannot delete/replace item in deleted/replaced section + [_deletedSectionIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { + [_replacedItemIndexes[idx] removeAllIndexes]; + [_deletedItemIndexes[idx] removeAllIndexes]; + }]; + [_replacedSectionIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { + [_replacedItemIndexes[idx] removeAllIndexes]; + [_deletedItemIndexes[idx] removeAllIndexes]; + }]; + [_data removeObjectsAtIndexes:_deletedSectionIndexes]; + + // Randomly insert some sections + _insertedSectionIndexes = [NSIndexSet randomIndexesLessThan:(_data.count + 1) probability:kFickleness insertMode:YES]; + _insertedSections = [ASThrashTestSection sectionsWithCount:_insertedSectionIndexes.count]; + [_data insertObjects:_insertedSections atIndexes:_insertedSectionIndexes]; + + // Randomly insert some items + for (ASThrashTestSection *section in _data) { + // Only insert items into the old sections – not replaced/inserted sections. + if ([_oldData containsObject:section]) { + NSMutableIndexSet *indexes = [NSIndexSet randomIndexesLessThan:(section.items.count + 1) probability:kFickleness insertMode:YES]; + NSArray *newItems = [ASThrashTestItem itemsWithCount:indexes.count]; + [section.items insertObjects:newItems atIndexes:indexes]; + [_insertedItems addObject:newItems]; + [_insertedItemIndexes addObject:indexes]; + } else { + [_insertedItems addObject:@[]]; + [_insertedItemIndexes addObject:[NSMutableIndexSet indexSet]]; + } + } + } + return self; +} + ++ (BOOL)supportsSecureCoding +{ + return YES; +} + ++ (ASThrashUpdate *)thrashUpdateWithBase64String:(NSString *)base64 +{ + return [NSKeyedUnarchiver unarchiveObjectWithData:[[NSData alloc] initWithBase64EncodedString:base64 options:kNilOptions]]; +} + +- (NSString *)base64Representation +{ + return [[NSKeyedArchiver archivedDataWithRootObject:self] base64EncodedStringWithOptions:kNilOptions]; +} + +- (void)encodeWithCoder:(NSCoder *)aCoder +{ + NSDictionary *dict = [self dictionaryWithValuesForKeys:@[ + @"oldData", + @"data", + @"deletedSectionIndexes", + @"replacedSectionIndexes", + @"replacingSections", + @"insertedSectionIndexes", + @"insertedSections", + @"deletedItemIndexes", + @"replacedItemIndexes", + @"replacingItems", + @"insertedItemIndexes", + @"insertedItems" + ]]; + [aCoder encodeObject:dict forKey:@"_dict"]; + [aCoder encodeInteger:ASThrashUpdateCurrentSerializationVersion forKey:@"_version"]; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super init]; + if (self != nil) { + NSAssert(ASThrashUpdateCurrentSerializationVersion == [aDecoder decodeIntegerForKey:@"_version"], @"This thrash update was archived from a different version and can't be read. Sorry."); + NSDictionary *dict = [aDecoder decodeObjectOfClass:[NSDictionary class] forKey:@"_dict"]; + [self setValuesForKeysWithDictionary:dict]; + } + return self; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"", self, ASThrashArrayDescription(_oldData), ASThrashArrayDescription(_deletedItemIndexes), _deletedSectionIndexes, ASThrashArrayDescription(_replacedItemIndexes), _replacedSectionIndexes, ASThrashArrayDescription(_insertedItemIndexes), _insertedSectionIndexes, ASThrashArrayDescription(_data)]; +} + +- (NSString *)logFriendlyBase64Representation +{ + return [NSString stringWithFormat:@"\n\n**********\nBase64 Representation:\n**********\n%@\n**********\nEnd Base64 Representation\n**********", self.base64Representation]; +} + +@end