From 088d93270737eee55579ab08162428f2f8b9d902 Mon Sep 17 00:00:00 2001 From: Maxime Ollivier Date: Tue, 8 Sep 2020 09:06:16 -0700 Subject: [PATCH] duplicate IGListAdapterUpdater to begin refactor Summary: There's a few issues with `IGListAdapterUpdater` which would be difficult to safely fix "in place", so lets create a duplicate updater that we can test separatly. Ugh, copy paste? * The alternative would be to change `IGListAdapterUpdater` directly and have lots of `if/else` branching. I've tried it and it's hard to follow and easy to break. By creating a separate class, we do run the risk of having 2 diverging updaters, but I think it's worth the risk since it rarely gets changed. I'll try to ship (or burn) this new updater quickly. Why duplicate `IGListAdapterUpdater` rather then starting from scratch? * I want to take advantage of the existing unit tests. I can make small incremental changes and make sure the tests still pass. * There's a lot going on in `IGListAdapterUpdater` and it's easier to refactor it by moving things around, rather then writing it from nothing. Why does `IGListAdapterUpdater` need a clean up? * Check out the first diff in the stack or task T74605897. Reviewed By: patters Differential Revision: D23145777 fbshipit-source-id: 360e980a89a5681ce38d1b5f6f1ca035eb1eb195 --- .../IGListExperimentalAdapterUpdater.h | 31 + .../IGListExperimentalAdapterUpdater.m | 600 +++++ ...IGListExperimentalAdapterUpdaterInternal.h | 45 + Tests/IGListExperimentalAdapterE2ETests.m | 1979 +++++++++++++++++ Tests/IGListExperimentalAdapterUpdaterTests.m | 1069 +++++++++ 5 files changed, 3724 insertions(+) create mode 100644 Source/IGListKit/IGListExperimentalAdapterUpdater.h create mode 100644 Source/IGListKit/IGListExperimentalAdapterUpdater.m create mode 100644 Source/IGListKit/Internal/IGListExperimentalAdapterUpdaterInternal.h create mode 100644 Tests/IGListExperimentalAdapterE2ETests.m create mode 100644 Tests/IGListExperimentalAdapterUpdaterTests.m diff --git a/Source/IGListKit/IGListExperimentalAdapterUpdater.h b/Source/IGListKit/IGListExperimentalAdapterUpdater.h new file mode 100644 index 00000000..082b012d --- /dev/null +++ b/Source/IGListKit/IGListExperimentalAdapterUpdater.h @@ -0,0 +1,31 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Temporary class to test a more reliable, performant, and cleaner `IGListAdapterUpdater`. + + An `IGListAdapterUpdater` is a concrete type that conforms to `IGListUpdatingDelegate`. + It is an out-of-box updater for `IGListAdapter` objects to use. + + @note This updater performs re-entrant, coalesced updating for a list. It also uses a least-minimal diff + for calculating UI updates when `IGListAdapter` calls + `-performUpdateWithCollectionView:fromObjects:toObjects:completion:`. + */ +IGLK_SUBCLASSING_RESTRICTED +NS_SWIFT_NAME(ListExperimentalAdapterUpdater) +@interface IGListExperimentalAdapterUpdater : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/IGListKit/IGListExperimentalAdapterUpdater.m b/Source/IGListKit/IGListExperimentalAdapterUpdater.m new file mode 100644 index 00000000..66593635 --- /dev/null +++ b/Source/IGListKit/IGListExperimentalAdapterUpdater.m @@ -0,0 +1,600 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "IGListExperimentalAdapterUpdater.h" +#import "IGListExperimentalAdapterUpdaterInternal.h" + +#import + +#import "IGListAdapterUpdaterHelpers.h" +#import "IGListArrayUtilsInternal.h" +#import "IGListIndexSetResultInternal.h" +#import "IGListMoveIndexPathInternal.h" +#import "IGListReloadIndexPath.h" +#import "UICollectionView+IGListBatchUpdateData.h" + +typedef void (^IGListAdapterUpdaterDiffResultBlock)(IGListIndexSetResult *); +typedef void (^IGListAdapterUpdaterBlock)(void); +typedef void (^IGListAdapterUpdaterCompletionBlock)(BOOL); + +@implementation IGListExperimentalAdapterUpdater + +@synthesize delegate = _delegate; +@synthesize sectionMovesAsDeletesInserts = _sectionMovesAsDeletesInserts; +@synthesize singleItemSectionUpdates = _singleItemSectionUpdates; +@synthesize preferItemReloadsForSectionReloads = _preferItemReloadsForSectionReloads; +@synthesize allowsBackgroundReloading = _allowsBackgroundReloading; +@synthesize allowsReloadingOnTooManyUpdates = _allowsReloadingOnTooManyUpdates; +@synthesize experiments = _experiments; + +- (instancetype)init { + IGAssertMainThread(); + + if (self = [super init]) { + // the default is to use animations unless NO is passed + _queuedUpdateIsAnimated = YES; + _completionBlocks = [NSMutableArray new]; + _batchUpdates = [IGListBatchUpdates new]; + _allowsBackgroundReloading = YES; + _allowsReloadingOnTooManyUpdates = YES; + } + return self; +} + +#pragma mark - Private API + +- (BOOL)hasChanges { + return self.hasQueuedReloadData + || [self.batchUpdates hasChanges] + || self.fromObjects != nil + || self.toObjectsBlock != nil; +} + +- (void)performReloadDataWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock { + IGAssertMainThread(); + + id delegate = self.delegate; + void (^reloadUpdates)(void) = self.reloadUpdates; + IGListBatchUpdates *batchUpdates = self.batchUpdates; + NSMutableArray *completionBlocks = [self.completionBlocks mutableCopy]; + + [self cleanStateBeforeUpdates]; + + void (^executeCompletionBlocks)(BOOL) = ^(BOOL finished) { + for (IGListUpdatingCompletion block in completionBlocks) { + block(finished); + } + + self.state = IGListBatchUpdateStateIdle; + }; + + // bail early if the collection view has been deallocated in the time since the update was queued + UICollectionView *collectionView = collectionViewBlock(); + if (collectionView == nil) { + [self _cleanStateAfterUpdates]; + executeCompletionBlocks(NO); + [_delegate listAdapterUpdater:self didFinishWithoutUpdatesWithCollectionView:collectionView]; + return; + } + + // item updates must not send mutations to the collection view while we are reloading + self.state = IGListBatchUpdateStateExecutingBatchUpdateBlock; + + if (reloadUpdates) { + reloadUpdates(); + } + + // execute all stored item update blocks even if we are just calling reloadData. the actual collection view + // mutations will be discarded, but clients are encouraged to put their actual /data/ mutations inside the + // update block as well, so if we don't execute the block the changes will never happen + for (IGListItemUpdateBlock itemUpdateBlock in batchUpdates.itemUpdateBlocks) { + itemUpdateBlock(); + } + + // add any completion blocks from item updates. added after item blocks are executed in order to capture any + // re-entrant updates + [completionBlocks addObjectsFromArray:batchUpdates.itemCompletionBlocks]; + + self.state = IGListBatchUpdateStateExecutedBatchUpdateBlock; + + [self _cleanStateAfterUpdates]; + + [delegate listAdapterUpdater:self willReloadDataWithCollectionView:collectionView isFallbackReload:NO]; + [collectionView reloadData]; + [collectionView.collectionViewLayout invalidateLayout]; + [collectionView layoutIfNeeded]; + [delegate listAdapterUpdater:self didReloadDataWithCollectionView:collectionView isFallbackReload:NO]; + + executeCompletionBlocks(YES); +} + +- (void)performBatchUpdatesWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock { + IGAssertMainThread(); + IGAssert(self.state == IGListBatchUpdateStateIdle, @"Should not call batch updates when state isn't idle"); + + // create local variables so we can immediately clean our state but pass these items into the batch update block + id delegate = self.delegate; + NSArray *fromObjects = [self.fromObjects copy]; + IGListToObjectBlock toObjectsBlock = [self.toObjectsBlock copy]; + NSMutableArray *completionBlocks = [self.completionBlocks mutableCopy]; + void (^objectTransitionBlock)(NSArray *) = [self.objectTransitionBlock copy]; + const BOOL animated = self.queuedUpdateIsAnimated; + const BOOL allowsReloadingOnTooManyUpdates = self.allowsReloadingOnTooManyUpdates; + const IGListExperiment experiments = self.experiments; + IGListBatchUpdates *batchUpdates = self.batchUpdates; + + // clean up all state so that new updates can be coalesced while the current update is in flight + [self cleanStateBeforeUpdates]; + + void (^executeCompletionBlocks)(BOOL) = ^(BOOL finished) { + self.applyingUpdateData = nil; + self.state = IGListBatchUpdateStateIdle; + + for (IGListUpdatingCompletion block in completionBlocks) { + block(finished); + } + }; + + // bail early if the collection view has been deallocated in the time since the update was queued + UICollectionView *collectionView = collectionViewBlock(); + if (collectionView == nil) { + [self _cleanStateAfterUpdates]; + executeCompletionBlocks(NO); + [_delegate listAdapterUpdater:self didFinishWithoutUpdatesWithCollectionView:collectionView]; + return; + } + + NSArray *toObjects = nil; + if (toObjectsBlock != nil) { + toObjects = objectsWithDuplicateIdentifiersRemoved(toObjectsBlock()); + } +#ifdef DEBUG + for (id obj in toObjects) { + IGAssert([obj conformsToProtocol:@protocol(IGListDiffable)], + @"In order to use IGListAdapterUpdater, object %@ must conform to IGListDiffable", obj); + IGAssert([obj diffIdentifier] != nil, + @"Cannot have a nil diffIdentifier for object %@", obj); + } +#endif + + void (^executeUpdateBlocks)(void) = ^{ + self.state = IGListBatchUpdateStateExecutingBatchUpdateBlock; + + // run the update block so that the adapter can set its items. this makes sure that just before the update is + // committed that the data source is updated to the /latest/ "toObjects". this makes the data source in sync + // with the items that the updater is transitioning to + if (objectTransitionBlock != nil) { + objectTransitionBlock(toObjects); + } + + // execute each item update block which should make calls like insert, delete, and reload for index paths + // we collect all mutations in corresponding sets on self, then filter based on UICollectionView shortcomings + // call after the objectTransitionBlock so section level mutations happen before any items + for (IGListItemUpdateBlock itemUpdateBlock in batchUpdates.itemUpdateBlocks) { + itemUpdateBlock(); + } + + // add any completion blocks from item updates. added after item blocks are executed in order to capture any + // re-entrant updates + [completionBlocks addObjectsFromArray:batchUpdates.itemCompletionBlocks]; + + self.state = IGListBatchUpdateStateExecutedBatchUpdateBlock; + }; + + void (^reloadDataFallback)(void) = ^{ + [delegate listAdapterUpdater:self willReloadDataWithCollectionView:collectionView isFallbackReload:YES]; + executeUpdateBlocks(); + [self _cleanStateAfterUpdates]; + [self _performBatchUpdatesItemBlockApplied]; + [collectionView reloadData]; + [collectionView layoutIfNeeded]; + executeCompletionBlocks(YES); + [delegate listAdapterUpdater:self didReloadDataWithCollectionView:collectionView isFallbackReload:YES]; + + // queue another update in case something changed during batch updates. this method will bail next runloop if + // there are no changes + [self _queueUpdateWithCollectionViewBlock:collectionViewBlock]; + }; + + // disables multiple performBatchUpdates: from happening at the same time + [self _beginPerformBatchUpdatesToObjects:toObjects]; + + // if the collection view isn't in a visible window, skip diffing and batch updating. execute all transition blocks, + // reload data, execute completion blocks, and get outta here + if (self.allowsBackgroundReloading && collectionView.window == nil) { + reloadDataFallback(); + return; + } + + // block executed in the first param block of -[UICollectionView performBatchUpdates:completion:] + void (^batchUpdatesBlock)(IGListIndexSetResult *result) = ^(IGListIndexSetResult *result){ + executeUpdateBlocks(); + if (self.singleItemSectionUpdates) { + [collectionView deleteSections:result.deletes]; + [collectionView insertSections:result.inserts]; + for (IGListMoveIndex *move in result.moves) { + [collectionView moveSection:move.from toSection:move.to]; + } + // NOTE: for section updates, it's updated in the IGListSectionController's -didUpdateToObject:, since there is *only* 1 cell for the section, we can just update that cell. + + self.applyingUpdateData = [[IGListBatchUpdateData alloc] + initWithInsertSections:result.inserts + deleteSections:result.deletes + moveSections:[NSSet setWithArray:result.moves] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + } else { + self.applyingUpdateData = IGListApplyUpdatesToCollectionView(collectionView, + result, + self.batchUpdates, + fromObjects, + self.sectionMovesAsDeletesInserts, + self.preferItemReloadsForSectionReloads); + } + + [self _cleanStateAfterUpdates]; + [self _performBatchUpdatesItemBlockApplied]; + }; + + // block used as the second param of -[UICollectionView performBatchUpdates:completion:] + void (^fallbackWithoutUpdates)(void) = ^(void) { + executeCompletionBlocks(NO); + + [delegate listAdapterUpdater:self didFinishWithoutUpdatesWithCollectionView:collectionView]; + + // queue another update in case something changed during batch updates. this method will bail next runloop if + // there are no changes + [self _queueUpdateWithCollectionViewBlock:collectionViewBlock]; + }; + + // block used as the second param of -[UICollectionView performBatchUpdates:completion:] + void (^batchUpdatesCompletionBlock)(BOOL) = ^(BOOL finished) { + IGListBatchUpdateData *oldApplyingUpdateData = self.applyingUpdateData; + executeCompletionBlocks(finished); + + [delegate listAdapterUpdater:self didPerformBatchUpdates:oldApplyingUpdateData collectionView:collectionView]; + + // queue another update in case something changed during batch updates. this method will bail next runloop if + // there are no changes + [self _queueUpdateWithCollectionViewBlock:collectionViewBlock]; + }; + + void (^performUpdate)(IGListIndexSetResult *) = ^(IGListIndexSetResult *result){ + [delegate listAdapterUpdater:self +willPerformBatchUpdatesWithCollectionView:collectionView + fromObjects:fromObjects + toObjects:toObjects + listIndexSetResult:result + animated:animated]; + + // Wrap `[UICollectionView performBatchUpdates ...]` so that in case it crashes, the first app symbol will not be a block. A block name includes the + // line number, which means if you change the block line number, it will be categorized as a different crash. This makes tracking crashes + // across multiple app-versions a pain. + IGListAdapterUpdaterPerformBatchUpdate(collectionView, animated, ^{ + batchUpdatesBlock(result); + }, batchUpdatesCompletionBlock); + }; + + // block that executes the batch update and exception handling + void (^tryToPerformUpdate)(IGListIndexSetResult *) = ^(IGListIndexSetResult *result){ + @try { + if (collectionView.dataSource == nil) { + // If the data source is nil, we should not call any collection view update. + fallbackWithoutUpdates(); + } else if (result.changeCount > 100 && allowsReloadingOnTooManyUpdates) { + reloadDataFallback(); + } else { + performUpdate(result); + } + } @catch (NSException *exception) { + [delegate listAdapterUpdater:self + collectionView:collectionView + willCrashWithException:exception + fromObjects:fromObjects + toObjects:toObjects + diffResult:result + updates:(id)self.applyingUpdateData]; + @throw exception; + } + }; + + const BOOL onBackgroundThread = IGListExperimentEnabled(experiments, IGListExperimentBackgroundDiffing); + [delegate listAdapterUpdater:self willDiffFromObjects:fromObjects toObjects:toObjects]; + IGListAdapterUpdaterPerformDiffing(fromObjects, toObjects, IGListDiffEquality, onBackgroundThread, ^(IGListIndexSetResult *result){ + [delegate listAdapterUpdater:self didDiffWithResults:result onBackgroundThread:onBackgroundThread]; + tryToPerformUpdate(result); + }); +} + +- (void)_beginPerformBatchUpdatesToObjects:(NSArray *)toObjects { + self.pendingTransitionToObjects = toObjects; + self.state = IGListBatchUpdateStateQueuedBatchUpdate; +} + +- (void)_performBatchUpdatesItemBlockApplied { + self.pendingTransitionToObjects = nil; +} + +- (void)cleanStateBeforeUpdates { + self.queuedUpdateIsAnimated = YES; + + // destroy to/from transition items + self.fromObjects = nil; + self.toObjectsBlock = nil; + + // destroy reloadData state + self.reloadUpdates = nil; + self.queuedReloadData = NO; + + // remove indexpath/item changes + self.objectTransitionBlock = nil; + + // removes all object completion blocks. done before updates to start collecting completion blocks for coalesced + // or re-entrant object updates + [self.completionBlocks removeAllObjects]; +} + +- (void)_cleanStateAfterUpdates { + self.batchUpdates = [IGListBatchUpdates new]; +} + +- (void)_queueUpdateWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock { + IGAssertMainThread(); + + __weak __typeof__(self) weakSelf = self; + + // dispatch_async to give the main queue time to collect more batch updates so that a minimum amount of work + // (diffing, etc) is done on main. dispatch_async does not garauntee a full runloop turn will pass though. + // see -performUpdateWithCollectionView:fromObjects:toObjects:animated:objectTransitionBlock:completion: for more + // details on how coalescence is done. + dispatch_async(dispatch_get_main_queue(), ^{ + if (weakSelf.state != IGListBatchUpdateStateIdle + || ![weakSelf hasChanges]) { + return; + } + + if (weakSelf.hasQueuedReloadData) { + [weakSelf performReloadDataWithCollectionViewBlock:collectionViewBlock]; + } else { + [weakSelf performBatchUpdatesWithCollectionViewBlock:collectionViewBlock]; + } + }); +} + +#pragma mark - IGListUpdatingDelegate + +static BOOL IGListIsEqual(const void *a, const void *b, NSUInteger (*size)(const void *item)) { + const id left = (__bridge id)a; + const id right = (__bridge id)b; + return [left class] == [right class] + && [[left diffIdentifier] isEqual:[right diffIdentifier]]; +} + +// since the diffing algo used in this updater keys items based on their -diffIdentifier, we must use a map table that +// precisely mimics this behavior +static NSUInteger IGListIdentifierHash(const void *item, NSUInteger (*size)(const void *item)) { + return [[(__bridge id)item diffIdentifier] hash]; +} + +- (NSPointerFunctions *)objectLookupPointerFunctions { + NSPointerFunctions *functions = [NSPointerFunctions pointerFunctionsWithOptions:NSPointerFunctionsStrongMemory]; + functions.hashFunction = IGListIdentifierHash; + functions.isEqualFunction = IGListIsEqual; + return functions; +} + +- (void)performUpdateWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock + fromObjects:(NSArray *)fromObjects + toObjectsBlock:(IGListToObjectBlock)toObjectsBlock + animated:(BOOL)animated + objectTransitionBlock:(IGListObjectTransitionBlock)objectTransitionBlock + completion:(IGListUpdatingCompletion)completion { + IGAssertMainThread(); + IGParameterAssert(collectionViewBlock != nil); + IGParameterAssert(objectTransitionBlock != nil); + + // only update the items that we are coming from if it has not been set + // this allows multiple updates to be called while an update is already in progress, and the transition from > to + // will be done on the first "fromObjects" received and the last "toObjects" + // if performBatchUpdates: hasn't applied the update block, then data source hasn't transitioned its state. if an + // update is queued in between then we must use the pending toObjects + self.fromObjects = self.fromObjects ?: self.pendingTransitionToObjects ?: fromObjects; + self.toObjectsBlock = toObjectsBlock; + + // disabled animations will always take priority + // reset to YES in -cleanupState + self.queuedUpdateIsAnimated = self.queuedUpdateIsAnimated && animated; + + // always use the last update block, even though this should always do the exact same thing + self.objectTransitionBlock = objectTransitionBlock; + + IGListUpdatingCompletion localCompletion = completion; + if (localCompletion) { + [self.completionBlocks addObject:localCompletion]; + } + + [self _queueUpdateWithCollectionViewBlock:collectionViewBlock]; +} + +- (void)performUpdateWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock + animated:(BOOL)animated + itemUpdates:(void (^)(void))itemUpdates + completion:(void (^)(BOOL))completion { + IGAssertMainThread(); + IGParameterAssert(collectionViewBlock != nil); + IGParameterAssert(itemUpdates != nil); + + IGListBatchUpdates *batchUpdates = self.batchUpdates; + if (completion != nil) { + [batchUpdates.itemCompletionBlocks addObject:completion]; + } + + // if already inside the execution of the update block, immediately unload the itemUpdates block. + // the completion blocks are executed later in the lifecycle, so that still needs to be added to the batch + if (self.state == IGListBatchUpdateStateExecutingBatchUpdateBlock) { + itemUpdates(); + } else { + [batchUpdates.itemUpdateBlocks addObject:itemUpdates]; + + // disabled animations will always take priority + // reset to YES in -cleanupState + self.queuedUpdateIsAnimated = self.queuedUpdateIsAnimated && animated; + + [self _queueUpdateWithCollectionViewBlock:collectionViewBlock]; + } +} + +- (void)insertItemsIntoCollectionView:(UICollectionView *)collectionView indexPaths:(NSArray *)indexPaths { + IGAssertMainThread(); + IGParameterAssert(collectionView != nil); + IGParameterAssert(indexPaths != nil); + if (self.state == IGListBatchUpdateStateExecutingBatchUpdateBlock) { + [self.batchUpdates.itemInserts addObjectsFromArray:indexPaths]; + } else { + [self.delegate listAdapterUpdater:self willInsertIndexPaths:indexPaths collectionView:collectionView]; + [collectionView insertItemsAtIndexPaths:indexPaths]; + } +} + +- (void)deleteItemsFromCollectionView:(UICollectionView *)collectionView indexPaths:(NSArray *)indexPaths { + IGAssertMainThread(); + IGParameterAssert(collectionView != nil); + IGParameterAssert(indexPaths != nil); + if (self.state == IGListBatchUpdateStateExecutingBatchUpdateBlock) { + [self.batchUpdates.itemDeletes addObjectsFromArray:indexPaths]; + } else { + [self.delegate listAdapterUpdater:self willDeleteIndexPaths:indexPaths collectionView:collectionView]; + [collectionView deleteItemsAtIndexPaths:indexPaths]; + } +} + +- (void)moveItemInCollectionView:(UICollectionView *)collectionView + fromIndexPath:(NSIndexPath *)fromIndexPath + toIndexPath:(NSIndexPath *)toIndexPath { + if (self.state == IGListBatchUpdateStateExecutingBatchUpdateBlock) { + IGListMoveIndexPath *move = [[IGListMoveIndexPath alloc] initWithFrom:fromIndexPath to:toIndexPath]; + [self.batchUpdates.itemMoves addObject:move]; + } else { + [self.delegate listAdapterUpdater:self willMoveFromIndexPath:fromIndexPath toIndexPath:toIndexPath collectionView:collectionView]; + [collectionView moveItemAtIndexPath:fromIndexPath toIndexPath:toIndexPath]; + } +} + +- (void)reloadItemInCollectionView:(UICollectionView *)collectionView + fromIndexPath:(NSIndexPath *)fromIndexPath + toIndexPath:(NSIndexPath *)toIndexPath { + if (self.state == IGListBatchUpdateStateExecutingBatchUpdateBlock) { + IGListReloadIndexPath *reload = [[IGListReloadIndexPath alloc] initWithFromIndexPath:fromIndexPath toIndexPath:toIndexPath]; + [self.batchUpdates.itemReloads addObject:reload]; + } else { + [self.delegate listAdapterUpdater:self willReloadIndexPaths:@[fromIndexPath] collectionView:collectionView]; + [collectionView reloadItemsAtIndexPaths:@[fromIndexPath]]; + } +} + +- (void)moveSectionInCollectionView:(UICollectionView *)collectionView + fromIndex:(NSInteger)fromIndex + toIndex:(NSInteger)toIndex { + IGAssertMainThread(); + IGParameterAssert(collectionView != nil); + + // iOS expects interactive reordering to be movement of items not sections + // after moving a single-item section controller, + // you end up with two items in the section for the drop location, + // and zero items in the section originating at the drag location + // so, we have to reload data rather than doing a section move + + [collectionView reloadData]; + + // It seems that reloadData called during UICollectionView's moveItemAtIndexPath + // delegate call does not reload all cells as intended + // So, we further reload all visible sections to make sure none of our cells + // are left with data that's out of sync with our dataSource + + id delegate = self.delegate; + + NSMutableIndexSet *visibleSections = [NSMutableIndexSet new]; + NSArray *visibleIndexPaths = [collectionView indexPathsForVisibleItems]; + for (NSIndexPath *visibleIndexPath in visibleIndexPaths) { + [visibleSections addIndex:visibleIndexPath.section]; + } + + [delegate listAdapterUpdater:self willReloadSections:visibleSections collectionView:collectionView]; + + // prevent double-animation from reloadData + reloadSections + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + [collectionView performBatchUpdates:^{ + [collectionView reloadSections:visibleSections]; + } completion:^(BOOL finished) { + [CATransaction commit]; + }]; +} + +- (void)reloadDataWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock + reloadUpdateBlock:(IGListReloadUpdateBlock)reloadUpdateBlock + completion:(nullable IGListUpdatingCompletion)completion { + IGAssertMainThread(); + IGParameterAssert(collectionViewBlock != nil); + IGParameterAssert(reloadUpdateBlock != nil); + + IGListUpdatingCompletion localCompletion = completion; + if (localCompletion) { + [self.completionBlocks addObject:localCompletion]; + } + + self.reloadUpdates = reloadUpdateBlock; + self.queuedReloadData = YES; + [self _queueUpdateWithCollectionViewBlock:collectionViewBlock]; +} + +- (void)reloadCollectionView:(UICollectionView *)collectionView sections:(NSIndexSet *)sections { + IGAssertMainThread(); + IGParameterAssert(collectionView != nil); + IGParameterAssert(sections != nil); + if (self.state == IGListBatchUpdateStateExecutingBatchUpdateBlock) { + [self.batchUpdates.sectionReloads addIndexes:sections]; + } else { + [self.delegate listAdapterUpdater:self willReloadSections:sections collectionView:collectionView]; + [collectionView reloadSections:sections]; + } +} + +#pragma mark - Helpers + +static void IGListAdapterUpdaterPerformBatchUpdate(UICollectionView *collectionView, BOOL animated, IGListAdapterUpdaterBlock updates, IGListAdapterUpdaterCompletionBlock completion) { + if (animated) { + [collectionView performBatchUpdates:updates completion:completion]; + } else { + [UIView performWithoutAnimation:^{ + [collectionView performBatchUpdates:updates completion:completion]; + }]; + } +} + +static void IGListAdapterUpdaterPerformDiffing(NSArray> *_Nullable oldArray, + NSArray> *_Nullable newArray, + IGListDiffOption option, + BOOL onBackgroundThread, + IGListAdapterUpdaterDiffResultBlock completion) { + if (onBackgroundThread) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + IGListIndexSetResult *result = IGListDiff(oldArray, newArray, option); + dispatch_async(dispatch_get_main_queue(), ^{ + completion(result); + }); + }); + } else { + IGListIndexSetResult *result = IGListDiff(oldArray, newArray, option); + completion(result); + } +} + +@end diff --git a/Source/IGListKit/Internal/IGListExperimentalAdapterUpdaterInternal.h b/Source/IGListKit/Internal/IGListExperimentalAdapterUpdaterInternal.h new file mode 100644 index 00000000..8131ffcd --- /dev/null +++ b/Source/IGListKit/Internal/IGListExperimentalAdapterUpdaterInternal.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import + +#import + +#import "IGListExperimentalAdapterUpdater.h" +#import "IGListBatchUpdateState.h" +#import "IGListBatchUpdates.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface IGListExperimentalAdapterUpdater () + +@property (nonatomic, copy, nullable) NSArray *fromObjects; +@property (nonatomic, copy, nullable) IGListToObjectBlock toObjectsBlock; +@property (nonatomic, copy, nullable) NSArray *pendingTransitionToObjects; +@property (nonatomic, strong) NSMutableArray *completionBlocks; + +@property (nonatomic, assign) BOOL queuedUpdateIsAnimated; + +@property (nonatomic, strong) IGListBatchUpdates *batchUpdates; + +@property (nonatomic, copy, nullable) IGListObjectTransitionBlock objectTransitionBlock; + +@property (nonatomic, copy, nullable) IGListReloadUpdateBlock reloadUpdates; +@property (nonatomic, assign, getter=hasQueuedReloadData) BOOL queuedReloadData; + +@property (nonatomic, assign) IGListBatchUpdateState state; +@property (nonatomic, strong, nullable) IGListBatchUpdateData *applyingUpdateData; + +- (void)performReloadDataWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock; +- (void)performBatchUpdatesWithCollectionViewBlock:(IGListCollectionViewBlock)collectionViewBlock; +- (void)cleanStateBeforeUpdates; +- (BOOL)hasChanges; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Tests/IGListExperimentalAdapterE2ETests.m b/Tests/IGListExperimentalAdapterE2ETests.m new file mode 100644 index 00000000..82097089 --- /dev/null +++ b/Tests/IGListExperimentalAdapterE2ETests.m @@ -0,0 +1,1979 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +#import + +#import "IGListAdapterInternal.h" +#import "IGListAdapterUpdateTester.h" +#import "IGListExperimentalAdapterUpdater.h" +#import "IGListTestCase.h" +#import "IGListTestHelpers.h" +#import "IGListTestOffsettingLayout.h" +#import "IGTestCell.h" +#import "IGTestDelegateController.h" +#import "IGTestDelegateDataSource.h" +#import "IGTestObject.h" + +/// Equivalent of `IGListAdapterE2ETests` to test the new `IGListExperimentalAdapterUpdater` +@interface IGListExperimentalAdapterE2ETests : IGListTestCase +@end + +@implementation IGListExperimentalAdapterE2ETests + +- (void)setUp { + self.workingRangeSize = 2; + self.dataSource = [IGTestDelegateDataSource new]; + self.updater = [IGListExperimentalAdapterUpdater new]; + [super setUp]; +} + +- (void)test_whenSettingUpTest_thenCollectionViewIsLoaded { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @3) + ]]; + XCTAssertEqual(self.collectionView.numberOfSections, 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 3); +} + +- (void)test_whenUsingStringValue_thenCellLabelsAreConfigured { + [self setupWithObjects:@[ + genTestObject(@0, @"Foo"), + genTestObject(@1, @"Bar") + ]]; + + IGTestCell *cell = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:genIndexPath(0, 0)]; + XCTAssertEqualObjects(cell.label.text, @"Foo"); + XCTAssertEqual(cell.delegate, [self.adapter sectionControllerForObject:self.dataSource.objects[0]]); +} + +- (void)test_whenUpdating_withEqualObjects_thatCellConfigurationDoesntChange { + [self setupWithObjects:@[ + genTestObject(@0, @"Foo"), + genTestObject(@1, @"Bar") + ]]; + + // Get the section controller before we change the data source or perform updates + id c0 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + + // Set equal but new-instance objects on the data source + self.dataSource.objects = @[ + genTestObject(@0, @"Foo"), + genTestObject(@1, @"Bar") + ]; + + // Perform updates on the adapter and check that the cell config uses the same section controller as before the updates + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + IGTestCell *cell = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:genIndexPath(0, 0)]; + XCTAssertEqualObjects(cell.label.text, @"Foo"); + XCTAssertNotNil(cell.delegate); + XCTAssertEqual(cell.delegate, c0); + XCTAssertEqual(cell.delegate, [self.adapter sectionControllerForObject:self.dataSource.objects[0]]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingItem_cellConfigurationChanges { + [self setupWithObjects:@[ + genTestObject(@0, @"Foo"), + genTestObject(@1, @"Bar") + ]]; + + // make sure our cells are propertly configured + IGTestCell *cell1 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:genIndexPath(0, 0)]; + IGTestCell *cell2 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:genIndexPath(1, 0)]; + XCTAssertEqualObjects(cell1.label.text, @"Foo"); + XCTAssertEqualObjects(cell2.label.text, @"Bar"); + + // Change the string value of both instances in the data source + IGTestObject *item1 = self.dataSource.objects[0]; + item1.value = @"Baz"; + IGTestObject *item2 = self.dataSource.objects[1]; + item2.value = @"Quz"; + + // Only reload the first item, not the second + [self.adapter reloadObjects:@[item1]]; + + // The collection view will likely create new cells + cell1 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:genIndexPath(0, 0)]; + cell2 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:genIndexPath(1, 0)]; + + // Make sure that the cell in the first section was reloaded + XCTAssertEqualObjects(cell1.label.text, @"Baz"); + // The cell in the second section should not be reloaded and should equal the string value from setup + XCTAssertEqualObjects(cell2.label.text, @"Bar"); +} + +- (void)test_whenObjectEqualityChanges_thatSectionCountChanges { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + self.dataSource.objects = @[ + genTestObject(@1, @2), + genTestObject(@2, @3), // updated to 3 items (from 2) + genTestObject(@3, @2), // insert new object + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual(self.collectionView.numberOfSections, 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenUpdatesComplete_thatCellsExist { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + ]]; + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]); + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0]]); + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:1]]); + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:1]]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadDataCompletes_thatCellsExist { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + XCTestExpectation *expectation = genExpectation; + [self.adapter reloadDataWithCompletion:^(BOOL finished) { + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]); + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0]]); + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:1]]); + XCTAssertNotNil([self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:1]]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerInsertsIndexes_thatCountsAreUpdated { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext insertInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:2]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerDeletesIndexes_thatCountsAreUpdated { + // 2 sections each with 2 objects + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @1; + [batchContext deleteInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerReloadsIndexes_thatCellConfigurationUpdates { + [self setupWithObjects:@[ + genTestObject(@1, @"a"), + genTestObject(@2, @"b") + ]]; + XCTAssertEqual([self.collectionView numberOfSections], 2); + IGTestCell *cell = (IGTestCell *)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + XCTAssertEqualObjects(cell.label.text, @"a"); + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @"c"; + [batchContext reloadInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + IGTestCell *updatedCell = (IGTestCell *)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + XCTAssertEqualObjects(updatedCell.label.text, @"c"); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerReloads_thatCountsAreUpdated { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext reloadSectionController:sectionController]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerReloads_withPreferItemReload_thatCountsAreUpdated { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + // Prefer to use item reloads for section reloads if available. + [(IGListExperimentalAdapterUpdater *)self.adapter.updater setPreferItemReloadsForSectionReloads:YES]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext reloadSectionController:sectionController]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withSectionControllerMutations_thatCollectionCountsAreUpdated { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + IGTestObject *object1 = self.dataSource.objects[0]; + IGTestObject *object2 = self.dataSource.objects[1]; + + // insert a new object in front of the one we are doing an item-level insert on + self.dataSource.objects = @[ + genTestObject(@3, @1), // new + object1, + object2, + ]; + + IGListSectionController *sectionController1 = [self.adapter sectionControllerForObject:object1]; + IGListSectionController *sectionController2 = [self.adapter sectionControllerForObject:object2]; + + [self.adapter performUpdatesAnimated:YES completion:nil]; + + XCTestExpectation *expectation = genExpectation; + [sectionController1.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object1.value = @1; + object2.value = @3; + [batchContext deleteInSectionController:sectionController1 atIndexes:[NSIndexSet indexSetWithIndex:0]]; + [batchContext insertInSectionController:sectionController2 atIndexes:[NSIndexSet indexSetWithIndex:2]]; + [batchContext reloadInSectionController:sectionController2 atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + // 3 sections now b/c of the insert + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerMoves_withSectionControllerMutations_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + self.dataSource.objects = @[ + genTestObject(@2, @2), + object, // moved from 0 to 1 + ]; + + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + // queue the update that performs the section move + [self.adapter performUpdatesAnimated:YES completion:nil]; + + XCTestExpectation *expectation = genExpectation; + + // queue an item update that gets batched with the section move + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext insertInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:2]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + // the object we are tracking should now be in section 1 and have 3 items + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenItemIsRemoved_withSectionControllerMutations_thatCollectionViewWorks { + // 2 sections each with 2 objects + [self setupWithObjects:@[ + genTestObject(@2, @2), + genTestObject(@1, @2) + ]]; + IGTestObject *object = self.dataSource.objects[1]; + + // object at index 1 deleted + self.dataSource.objects = @[ + genTestObject(@2, @2), + ]; + + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + [self.adapter performUpdatesAnimated:YES completion:nil]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @1; + [batchContext deleteInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withUnequalItem_withItemMoving_thatCollectionViewCountsUpdate { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + ]]; + + self.dataSource.objects = @[ + genTestObject(@3, @2), + genTestObject(@1, @3), // moved from index 0 to 1, value changed from 2 to 3 + genTestObject(@2, @2), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withItemMoving_withSectionControllerReloadIndexes_thatCollectionViewCountsUpdate { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @3), + ]]; + + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + + self.dataSource.objects = @[ + genTestObject(@2, @3), + genTestObject(@1, @2), // moved from index 0 to 1 + ]; + + [self.adapter performUpdatesAnimated:YES completion:nil]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext reloadInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withSectionControllerReloadIndexes_withItemDeleted_thatCollectionViewCountsUpdate { + [self setupWithObjects:@[ + genTestObject(@1, @2), // item that will be deleted + genTestObject(@2, @3), + ]]; + + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + + self.dataSource.objects = @[ + genTestObject(@2, @3), + ]; + + [self.adapter performUpdatesAnimated:YES completion:nil]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext reloadInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withNewItemInstances_thatSectionControllersEqual { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2) + ]]; + + // grab section controllers before updating the objects + NSArray *beforeupdateObjects = self.dataSource.objects; + IGListSectionController *sectionController1 = [self.adapter sectionControllerForObject:beforeupdateObjects.firstObject]; + IGListSectionController *sectionController2 = [self.adapter sectionControllerForObject:beforeupdateObjects.lastObject]; + + self.dataSource.objects = @[ + genTestObject(@1, @3), // new instance, value changed from 2 to 3 + genTestObject(@2, @2), // new instance but unchanged + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + + NSArray *afterupdateObjects = [self.adapter objects]; + // pointer equality + XCTAssertEqual([self.adapter sectionControllerForObject:afterupdateObjects.firstObject], sectionController1); + XCTAssertEqual([self.adapter sectionControllerForObject:afterupdateObjects.lastObject], sectionController2); + + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingMultipleUpdates_withNewItemInstances_thatSectionControllersReceiveNewInstances { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + ]]; + + id object = self.dataSource.objects[0]; + IGTestDelegateController *sectionController = [self.adapter sectionControllerForObject:object]; + + // test delegate controller counts the number of times it receives -didUpdateToItem: + XCTAssertEqual(sectionController.updateCount, 1); + + self.dataSource.objects = @[ + object, // same object instance + genTestObject(@3, @2), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual(sectionController, [self.adapter sectionControllerForObject:[self.adapter objects][0]]); + + // should not have received -didUpdateToItem: since the instance did not change + XCTAssertEqual(sectionController.updateCount, 1); + + self.dataSource.objects = @[ + genTestObject(@1, @2), // new instance but equal + genTestObject(@3, @2), + ]; + + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished3) { + XCTAssertEqual(sectionController, [self.adapter sectionControllerForObject:[self.adapter objects][0]]); + + // a new instance was used, make sure the section controller was updated + XCTAssertEqual(sectionController.updateCount, 2); + + [expectation fulfill]; + }]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenQueryingCollectionContext_withNewItemInstances_thatSectionMatchesCurrentIndex { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + ]]; + + IGTestDelegateController *sectionController = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + self.dataSource.objects = @[ + genTestObject(@2, @2), + genTestObject(@1, @2), // new instance but equal + genTestObject(@3, @2), + ]; + + __block BOOL executedUpdateBlock = NO; + __weak __typeof__(sectionController) weakSectionController = sectionController; + sectionController.itemUpdateBlock = ^{ + executedUpdateBlock = YES; + XCTAssertEqual(weakSectionController.section, 1); + }; + + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished3) { + XCTAssertTrue(executedUpdateBlock); + + [expectation fulfill]; + }]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerMutates_withReloadData_thatSectionControllerMutationIsApplied { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + ]]; + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext insertInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:2]]; + } completion:nil]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter reloadDataWithCompletion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + + // check that the count of items in section 0 was updated from the previous batch update block + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenContentOffsetChanges_withPerformUpdates_thatCollectionViewWorks { + // this test layout changes the offset in -prepareLayout which occurs somewhere between the update block being + // applied and the completion block + self.collectionView.collectionViewLayout = [IGListTestOffsettingLayout new]; + + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + genTestObject(@3, @2), + ]]; + + // remove the last object to check that we don't access OOB section controller when the layout changes the offset + self.dataSource.objects = @[ + genTestObject(@1, @2), + genTestObject(@2, @2), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingItems_withNewItemInstances_thatSectionControllersReceiveNewInstances { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + genTestObject(@3, @2), + ]]; + + IGTestDelegateController *sectionController1 = [self.adapter sectionControllerForObject:genTestObject(@1, @2)]; + IGTestDelegateController *sectionController2 = [self.adapter sectionControllerForObject:genTestObject(@2, @2)]; + + NSArray *newObjects = @[ + genTestObject(@1, @3), + genTestObject(@2, @3), + ]; + [self.adapter reloadObjects:newObjects]; + + XCTAssertEqual(sectionController1.item, newObjects[0]); + XCTAssertEqual(sectionController2.item, newObjects[1]); + XCTAssertTrue([[self.adapter.sectionMap objects] indexOfObjectIdenticalTo:newObjects[0]] != NSNotFound); + XCTAssertTrue([[self.adapter.sectionMap objects] indexOfObjectIdenticalTo:newObjects[1]] != NSNotFound); +} + +- (void)test_whenReloadingItems_withPerformUpdates_thatReloadIsApplied { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @2), + genTestObject(@3, @3), + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGTestDelegateController *sectionController = [self.adapter sectionControllerForObject:object]; + + // using performBatchAnimated: to mimic re-entrant item reload + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @4; // from @1 + [self.adapter reloadObjects:@[object]]; + } completion:nil]; + + // object is moved from position 0 to 1 + // it is also mutated in the previous update block AND queued for a reload + self.dataSource.objects = @[ + genTestObject(@3, @3), + object, + genTestObject(@2, @2), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 4); // reloaded section + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenSectionControllerMutates_whenThereIsNoWindow_thatCollectionViewCountsAreUpdated { + // remove the collection view from self.window so that we use reloadData + [self.collectionView removeFromSuperview]; + + [self setupWithObjects:@[ + genTestObject(@1, @8) + ]]; + IGTestObject *object = self.dataSource.objects[0]; + + IGTestDelegateController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + // using performBatchAnimated: to mimic re-entrant item reload + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @6; // from @1 + + [batchContext reloadInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + [batchContext deleteInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(5, 3)]]; + [batchContext insertInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 6); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withoutSettingDataSource_thatCompletionBlockExecutes { + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView]; + IGListAdapter *adapter = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + adapter.collectionView = collectionView; + + self.dataSource.objects = @[ + genTestObject(@1, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + + // call -performUpdatesAnimated: before we have set the data source + [adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + + // since the data source isnt set, we complete syncronously. dispatch_async simulates setting the data source + // in a different runloop from the completion block so it should be set by the time we make our subsequent + // -performUpdatesAnimated: call + dispatch_async(dispatch_get_main_queue(), ^{ + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @2) + ]; + [adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + XCTAssertEqual([collectionView numberOfSections], 2); + [expectation fulfill]; + }]; + }); + }]; + + // setting the data source immediately queries it, since the collection view is also set + adapter.dataSource = self.dataSource; + // simulate display reloading data on the collection view + [collectionView layoutIfNeeded]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withItemsMovingInBlocks_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @0), + genTestObject(@2, @7), + genTestObject(@3, @8), + genTestObject(@4, @8), + genTestObject(@5, @8), + genTestObject(@6, @5), + genTestObject(@7, @8), + genTestObject(@8, @8), + genTestObject(@9, @8), + ]]; + + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView]; + IGListExperimentalAdapterUpdater *updater = [IGListExperimentalAdapterUpdater new]; + IGListAdapter *adapter = [[IGListAdapter alloc] initWithUpdater:updater viewController:nil]; + adapter.dataSource = self.dataSource; + adapter.collectionView = collectionView; + [collectionView layoutSubviews]; + + XCTAssertEqual([collectionView numberOfSections], 9); + + self.dataSource.objects = @[ + genTestObject(@1, @0), + genTestObject(@10, @5), + genTestObject(@11, @7), + genTestObject(@2, @7), + genTestObject(@3, @8), + genTestObject(@6, @5), // "moves" in front of 4, 5 but doesn't change index in array + genTestObject(@4, @8), + genTestObject(@5, @8), + genTestObject(@7, @8), + genTestObject(@8, @8), + ]; + + XCTestExpectation *expectation = genExpectation; + [adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual([collectionView numberOfSections], 10); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReleasingObjects_thatAssertDoesntFire { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + // if the adapter keeps a strong ref to self and uses an async method, this will hit asserts that a list item + // controller is nil. the adapter should be released and the completion block never called. + @autoreleasepool { + IGListExperimentalAdapterUpdater *updater = [[IGListExperimentalAdapterUpdater alloc] init]; + IGListAdapter *adapter = [[IGListAdapter alloc] initWithUpdater:updater viewController:nil workingRangeSize:2]; + adapter.collectionView = self.collectionView; + adapter.dataSource = self.dataSource; + [adapter performUpdatesAnimated:NO completion:^(BOOL finished) { + XCTAssertTrue(NO, @"Should not reach completion block for adapter"); + }]; + } + + self.collectionView = nil; + self.dataSource = nil; + + // queued after perform updates + XCTestExpectation *expectation = genExpectation; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenItemDeleted_withDisplayDelegate_thatDelegateReceivesDeletedItem { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @2), + ]]; + IGTestObject *object = self.dataSource.objects[0]; + + self.dataSource.objects = @[ + genTestObject(@2, @2), + ]; + + id mockDisplayHandler = [OCMockObject mockForProtocol:@protocol(IGListAdapterDelegate)]; + self.adapter.delegate = mockDisplayHandler; + + [[mockDisplayHandler expect] listAdapter:self.adapter didEndDisplayingObject:object atIndex:0]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished2) { + [mockDisplayHandler verify]; + XCTAssertTrue(finished2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenItemReloaded_withDisplacingMutations_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@3, @1), + genTestObject(@4, @1), + genTestObject(@5, @1), + ]]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @2), // reloaded + genTestObject(@5, @2), // reloaded + genTestObject(@4, @2), // reloaded + genTestObject(@3, @1), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertTrue(finished); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenCollectionViewAppears_thatWillDisplayEventsAreSent { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @2), + ]]; + IGTestDelegateController *ic1 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + XCTAssertEqual(ic1.willDisplayCount, 1); + XCTAssertEqual(ic1.didEndDisplayCount, 0); + XCTAssertEqual([ic1.willDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic1.didEndDisplayCellIndexes countForObject:@0], 0); + + IGTestDelegateController *ic2 = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + XCTAssertEqual(ic2.willDisplayCount, 1); + XCTAssertEqual(ic2.didEndDisplayCount, 0); + XCTAssertEqual([ic2.willDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic2.willDisplayCellIndexes countForObject:@1], 1); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@0], 0); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@1], 0); +} + +- (void)test_whenAdapterUpdates_withItemUpdated_thatdidEndDisplayEventsAreSent { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @2), + ]]; + IGTestDelegateController *ic1 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + IGTestDelegateController *ic2 = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1), // reloaded w/ 1 cell removed + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual(ic1.willDisplayCount, 1); + XCTAssertEqual(ic1.didEndDisplayCount, 0); + XCTAssertEqual([ic1.willDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic1.didEndDisplayCellIndexes countForObject:@0], 0); + + XCTAssertEqual(ic2.willDisplayCount, 1); + XCTAssertEqual(ic2.didEndDisplayCount, 0); + XCTAssertEqual([ic2.willDisplayCellIndexes countForObject:@1], 1); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@1], 1); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAdapterUpdates_withItemRemoved_thatdidEndDisplayEventsAreSent { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @2), + ]]; + IGTestDelegateController *ic1 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + IGTestDelegateController *ic2 = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + + self.dataSource.objects = @[ + genTestObject(@1, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual(ic1.willDisplayCount, 1); + XCTAssertEqual(ic1.didEndDisplayCount, 0); + XCTAssertEqual([ic1.willDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic1.didEndDisplayCellIndexes countForObject:@0], 0); + + XCTAssertEqual(ic2.willDisplayCount, 1); + XCTAssertEqual(ic2.didEndDisplayCount, 1); + XCTAssertEqual([ic2.willDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic2.willDisplayCellIndexes countForObject:@1], 1); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@1], 1); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAdapterUpdates_withEmptyItems_thatdidEndDisplayEventsAreSent { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @2), + ]]; + IGTestDelegateController *ic1 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + IGTestDelegateController *ic2 = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + + self.dataSource.objects = @[]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual(ic1.didEndDisplayCount, 1); + XCTAssertEqual([ic1.didEndDisplayCellIndexes countForObject:@0], 1); + + XCTAssertEqual(ic2.didEndDisplayCount, 1); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@0], 1); + XCTAssertEqual([ic2.didEndDisplayCellIndexes countForObject:@1], 1); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenBatchUpdating_withCellQuery_thatCellIsNil { + __block BOOL executed = NO; + __weak __typeof__(self) weakSelf = self; + void (^block)(IGTestDelegateController *) = ^(IGTestDelegateController *ic) { + executed = YES; + XCTAssertNil([weakSelf.adapter cellForItemAtIndex:0 sectionController:ic]); + }; + ((IGTestDelegateDataSource *)self.dataSource).cellConfigureBlock = block; + + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@3, @1), + ]]; + + // delete the last object from the original array + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@4, @1), + genTestObject(@5, @1), + genTestObject(@6, @1), + ]; + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdates_withWorkingRange_thatAccessingCellDoesntCrash { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@3, @1), + ]]; + + // section controller try to access a cell in -listAdapter:sectionControllerWillEnterWorkingRange: + // add items beyond the 100x100 frame so they access unavailable cells + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@3, @1), + genTestObject(@4, @1), + genTestObject(@5, @1), + genTestObject(@6, @1), + genTestObject(@7, @1), + genTestObject(@8, @1), + genTestObject(@9, @1), + genTestObject(@10, @1), + genTestObject(@11, @1), + ]; + XCTestExpectation *expectation = genExpectation; + + // this will call -collectionView:performBatchUpdates:, trigger collectionView:willDisplayCell:forItemAtIndexPath:, + // which kicks off the working range logic + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingItems_withDeleteAndInsertCollision_thatUpdateCanBeApplied { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @5), + genTestObject(@3, @1), + ]]; + + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + + XCTestExpectation *expectation = genExpectation; + [section.collectionContext performBatchAnimated:NO updates:^(id batchContext) { + [batchContext deleteInSectionController:section atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(1, 2)]]; + [batchContext insertInSectionController:section atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(1, 2)]]; + [batchContext reloadInSectionController:section atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(1, 4)]]; + } completion:^(BOOL finished) { + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingItems_withSectionInsertedInFront_thatUpdateCanBeApplied { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @5), + genTestObject(@3, @1), + ]]; + + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + + XCTestExpectation *expectation1 = genExpectation; + [section.collectionContext performBatchAnimated:NO updates:^(id batchContext) { + [batchContext reloadInSectionController:section atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(1, 4)]]; + } completion:^(BOOL finished) { + [expectation1 fulfill]; + }]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@4, @1), // insert to shift object @2 + genTestObject(@2, @5), + genTestObject(@3, @1), + ]; + + XCTestExpectation *expectation2 = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + [expectation2 fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingItems_withSectionDeletedInFront_thatUpdateCanBeApplied { + [self setupWithObjects:@[ + genTestObject(@1, @1), + genTestObject(@2, @5), + genTestObject(@3, @1), + ]]; + + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + + XCTestExpectation *expectation1 = genExpectation; + [section.collectionContext performBatchAnimated:NO updates:^(id batchContext) { + [batchContext reloadInSectionController:section atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(1, 4)]]; + } completion:^(BOOL finished) { + [expectation1 fulfill]; + }]; + + self.dataSource.objects = @[ + genTestObject(@2, @5), + genTestObject(@3, @1), + ]; + + XCTestExpectation *expectation2 = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + [expectation2 fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenDataSourceDeallocatedAfterUpdateQueued_thatUpdateSuccesfullyCompletes { + IGTestDelegateDataSource *dataSource = [IGTestDelegateDataSource new]; + dataSource.objects = @[genTestObject(@1, @1)]; + self.adapter.collectionView = self.collectionView; + self.adapter.dataSource = dataSource; + [self.collectionView layoutIfNeeded]; + + dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @2), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + [expectation fulfill]; + }]; + + dataSource = nil; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenQueuingUpdate_withSectionControllerBatchUpdate_thatSectionControllerNotRetained { + __weak id weakSectionController = nil; + __weak id weakAdapter = nil; + __weak id weakCollectionView = nil; + + @autoreleasepool { + IGListAdapter *adapter = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + IGTestDelegateDataSource *dataSource = [IGTestDelegateDataSource new]; + IGTestObject *object = genTestObject(@1, @2); + dataSource.objects = @[object]; + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:CGRectMake(0, 0, 100, 100) collectionViewLayout:[UICollectionViewFlowLayout new]]; + adapter.collectionView = collectionView; + adapter.dataSource = dataSource; + [collectionView layoutIfNeeded]; + XCTAssertEqual([collectionView numberOfSections], 1); + XCTAssertEqual([collectionView numberOfItemsInSection:0], 2); + + IGListSectionController *section = [adapter sectionControllerForObject:object]; + + [section.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext insertInSectionController:section atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished) {}]; + + dataSource.objects = @[object, genTestObject(@2, @2)]; + [adapter performUpdatesAnimated:YES completion:^(BOOL finished) {}]; + + weakAdapter = adapter; + weakCollectionView = collectionView; + weakSectionController = section; + + XCTAssertNotNil(weakAdapter); + XCTAssertNotNil(weakCollectionView); + XCTAssertNotNil(weakSectionController); + } + XCTAssertNil(weakAdapter); + XCTAssertNil(weakCollectionView); + XCTAssertNil(weakSectionController); +} + +- (void)test_whenMovingItems_withObjectMoving_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @2), + genTestObject(@2, @2), + genTestObject(@3, @2), + ]]; + + __block BOOL executed = NO; + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + [section.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext moveInSectionController:section fromIndex:0 toIndex:1]; + executed = YES; + } completion:nil]; + + self.dataSource.objects = @[ + genTestObject(@3, @2), + genTestObject(@1, @2), + genTestObject(@2, @2), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertTrue(executed); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenMovingItems_withObjectReloaded_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + __block BOOL executed = NO; + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + [section.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext moveInSectionController:section fromIndex:0 toIndex:1]; + executed = YES; + } completion:nil]; + + self.dataSource.objects = @[ + genTestObject(@1, @3), + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertTrue(executed); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenMovingItems_withObjectDeleted_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + __block BOOL executed = NO; + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + [section.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext moveInSectionController:section fromIndex:0 toIndex:1]; + executed = YES; + } completion:nil]; + + self.dataSource.objects = @[]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertTrue(executed); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenMovingItems_withObjectInsertedBefore_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + __block BOOL executed = NO; + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + [section.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext moveInSectionController:section fromIndex:0 toIndex:1]; + executed = YES; + } completion:nil]; + + [self setupWithObjects:@[ + genTestObject(@2, @2), + genTestObject(@1, @2), + ]]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertTrue(executed); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenMovingItems_thatCollectionViewWorks { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + IGTestCell *cell1 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + IGTestCell *cell2 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0]]; + cell1.label.text = @"foo"; + cell2.label.text = @"bar"; + + XCTestExpectation *expectation = genExpectation; + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + [section.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext moveInSectionController:section fromIndex:0 toIndex:1]; + } completion:^(BOOL finished) { + IGTestCell *movedCell1 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + IGTestCell *movedCell2 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0]]; + XCTAssertEqualObjects(movedCell1.label.text, @"bar"); + XCTAssertEqualObjects(movedCell2.label.text, @"foo"); + + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenInvalidatingSectionController_withSizeChange_thatCellsAreSameInstance_thatCellsFrameChanged { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + NSIndexPath *path1 = [NSIndexPath indexPathForItem:0 inSection:0]; + NSIndexPath *path2 = [NSIndexPath indexPathForItem:1 inSection:0]; + IGTestCell *cell1 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:path1]; + IGTestCell *cell2 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:path2]; + + XCTAssertEqual(cell1.frame.size.height, 10); + XCTAssertEqual(cell2.frame.size.height, 10); + + IGTestDelegateController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + section.height = 20.0; + + XCTestExpectation *expectation = genExpectation; + [section.collectionContext invalidateLayoutForSectionController:section completion:^(BOOL finished) { + XCTAssertEqual(cell1, [self.collectionView cellForItemAtIndexPath:path1]); + XCTAssertEqual(cell2, [self.collectionView cellForItemAtIndexPath:path2]); + XCTAssertEqual(cell1.frame.size.height, 20); + XCTAssertEqual(cell2.frame.size.height, 20); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAdaptersSwapCollectionViews_thatOldAdapterDoesntUpdateOldCollectionView { + IGListAdapter *adapter1 = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + IGTestDelegateDataSource *dataSource1 = [IGTestDelegateDataSource new]; + dataSource1.objects = @[genTestObject(@1, @2)]; + adapter1.dataSource = dataSource1; + adapter1.collectionView = self.collectionView; + + [self.collectionView layoutIfNeeded]; + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + + IGListAdapter *adapter2 = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + IGTestDelegateDataSource *dataSource2 = [IGTestDelegateDataSource new]; + dataSource2.objects = @[genTestObject(@1, @1), genTestObject(@2, @1)]; + adapter2.dataSource = dataSource2; + adapter2.collectionView = self.collectionView; + + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + + dataSource1.objects = @[genTestObject(@1, @2), genTestObject(@2, @2), genTestObject(@3, @2), genTestObject(@4, @2)]; + XCTestExpectation *expectation = genExpectation; + + [adapter1 performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAdaptersSwapCollectionViews_ { + IGListAdapter *adapter1 = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + IGTestDelegateDataSource *dataSource1 = [IGTestDelegateDataSource new]; + dataSource1.objects = @[genTestObject(@1, @2)]; + adapter1.dataSource = dataSource1; + adapter1.collectionView = self.collectionView; + + [self.collectionView layoutIfNeeded]; + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + + IGListAdapter *adapter2 = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + IGTestDelegateDataSource *dataSource2 = [IGTestDelegateDataSource new]; + dataSource2.objects = @[genTestObject(@1, @1), genTestObject(@2, @1)]; + adapter2.dataSource = dataSource2; + adapter2.collectionView = self.collectionView; + + [self.collectionView layoutIfNeeded]; + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + + dataSource2.objects = @[genTestObject(@1, @2), genTestObject(@2, @1), genTestObject(@3, @1), genTestObject(@4, @1)]; + XCTestExpectation *expectation = genExpectation; + + [adapter2 performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 4); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenDidUpdateAsyncReloads_withBatchUpdatesInProgress_thatReloadIsExecuted { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGTestDelegateController *section = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + + XCTestExpectation *expectation1 = genExpectation; + __weak __typeof__(section) weakSection = section; + section.itemUpdateBlock = ^{ + // currently inside -[IGListSectionController didUpdateToObject:], change the item (note: NEVER do this) manually + // so that the data powering numberOfItems changes (1 to 2). dispatch_async the update to skip outside of the + // -[UICollectionView performBatchUpdates:completion:] block execution + [weakSection.collectionContext performBatchAnimated:NO updates:^(id batchContext) { + weakSection.item = genTestObject(@1, @2); + [batchContext reloadSectionController:weakSection]; + } completion:^(BOOL finished) { + [expectation1 fulfill]; + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + }]; + }; + + // add an object so that a batch update is triggered (diff result has changes) + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + XCTestExpectation *expectation2 = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + // verify that the section still has 2 items since this completion executes AFTER the reload block above + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + [expectation2 fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)_test_whenInsertingItemsTwice_withDataUpdatedTwice_thatAllUpdatesAppliedWithoutException { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @4; + [batchContext insertInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + [batchContext insertInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 4); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)FIXME_test_whenDeletingItemsTwice_withDataUpdatedTwice_thatAllUpdatesAppliedWithoutException { + [self setupWithObjects:@[ + genTestObject(@1, @4), + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @2; + [batchContext deleteInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + [batchContext deleteInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingSameItemTwice_thatDeletesAndInsertsAreBalanced { + [self setupWithObjects:@[ + genTestObject(@1, @4), + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGListSectionController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expectation = genExpectation; + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + [batchContext reloadInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + [batchContext reloadInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 4); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenUpdateQueuedDuringBatch_thatUpdateCompletesWithoutCrashing { + [self setupWithObjects:@[ + genTestObject(@1, @4), + genTestObject(@2, @4), + genTestObject(@3, @4), + genTestObject(@4, @4), + ]]; + + IGTestObject *object = self.dataSource.objects[0]; + IGTestDelegateController *sectionController = [self.adapter sectionControllerForObject:object]; + + XCTestExpectation *expect1 = genExpectation; + XCTestExpectation *expect2 = genExpectation; + + [sectionController.collectionContext performBatchAnimated:YES updates:^(id batchContext) { + object.value = @3; + [batchContext deleteInSectionController:sectionController atIndexes:[NSIndexSet indexSetWithIndex:0]]; + + self.dataSource.objects = @[ + genTestObject(@2, @4), + genTestObject(@4, @4), + genTestObject(@1, @3), + ]; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 4); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 4); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 3); + [expect1 fulfill]; + }]; + } completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 4); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 4); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 4); + XCTAssertEqual([self.collectionView numberOfItemsInSection:3], 4); + [expect2 fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenMassiveUpdate_thatUpdateApplied { + // init empty + [self setupWithObjects:@[]]; + + NSMutableArray *objects = [NSMutableArray new]; + for (NSInteger i = 0; i < 3000; i++) { + [objects addObject:genTestObject(@(i + 1), @4)]; + } + self.dataSource.objects = objects; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 3000); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenInvalidatingInsideBatchUpdate_withSystemReleased_thatSystemNil_andCollectionViewDoesntCrashOnDealloc { + __weak id weakAdapter = nil; + __block BOOL executedItemUpdate = NO; + XCTestExpectation *expectation = genExpectation; + + @autoreleasepool { + self.dataSource.objects = @[ + genTestObject(@1, @"Bar"), + genTestObject(@0, @"Foo") + ]; + + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView]; + IGListExperimentalAdapterUpdater *updater = [IGListExperimentalAdapterUpdater new]; + IGListAdapter *adapter = [[IGListAdapter alloc] initWithUpdater:updater viewController:nil]; + adapter.dataSource = self.dataSource; + adapter.collectionView = collectionView; + [collectionView layoutIfNeeded]; + + IGTestDelegateController *section = [adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + + __weak typeof(section) weakSection = section; + section.itemUpdateBlock = ^{ + executedItemUpdate = YES; + [weakSection.collectionContext invalidateLayoutForSectionController:weakSection completion:nil]; + }; + + self.dataSource.objects = @[ + genTestObject(@1, @"Bar"), + genTestObject(@0, @"Foo") + ]; + + [adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertNotNil(collectionView); + XCTAssertNotNil(adapter); + [collectionView removeFromSuperview]; + [expectation fulfill]; + }]; + + weakAdapter = adapter; + XCTAssertNotNil(weakAdapter); + } + + [self waitForExpectationsWithTimeout:30 handler:^(NSError * _Nullable error) { + XCTAssertTrue(executedItemUpdate); + XCTAssertNil(weakAdapter); + }]; +} + +- (void)test_whenAddingMultipleUpdateListeners_withPerformUpdatesAnimated_thatEventsReceived { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener1 = [IGListAdapterUpdateTester new];; + IGListAdapterUpdateTester *listener2 = [IGListAdapterUpdateTester new];; + + [self.adapter addUpdateListener:listener1]; + [self.adapter addUpdateListener:listener2]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual(listener1.hits, 1); + XCTAssertEqual(listener1.animated, YES); + XCTAssertEqual(listener1.type, IGListAdapterUpdateTypePerformUpdates); + XCTAssertEqual(listener2.hits, 1); + XCTAssertEqual(listener2.animated, YES); + XCTAssertEqual(listener2.type, IGListAdapterUpdateTypePerformUpdates); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAddingMultipleUpdateListeners_withPerformUpdatesNotAnimated_thatEventsReceived { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener1 = [IGListAdapterUpdateTester new];; + IGListAdapterUpdateTester *listener2 = [IGListAdapterUpdateTester new];; + + [self.adapter addUpdateListener:listener1]; + [self.adapter addUpdateListener:listener2]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:NO completion:^(BOOL finished) { + XCTAssertEqual(listener1.hits, 1); + XCTAssertEqual(listener1.animated, NO); + XCTAssertEqual(listener1.type, IGListAdapterUpdateTypePerformUpdates); + XCTAssertEqual(listener2.hits, 1); + XCTAssertEqual(listener2.animated, NO); + XCTAssertEqual(listener2.type, IGListAdapterUpdateTypePerformUpdates); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAddingMultipleUpdateListeners_withReloadData_thatEventsReceived { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener1 = [IGListAdapterUpdateTester new];; + IGListAdapterUpdateTester *listener2 = [IGListAdapterUpdateTester new];; + + [self.adapter addUpdateListener:listener1]; + [self.adapter addUpdateListener:listener2]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter reloadDataWithCompletion:^(BOOL finished) { + XCTAssertEqual(listener1.hits, 1); + XCTAssertEqual(listener1.animated, NO); + XCTAssertEqual(listener1.type, IGListAdapterUpdateTypeReloadData); + XCTAssertEqual(listener2.hits, 1); + XCTAssertEqual(listener2.animated, NO); + XCTAssertEqual(listener2.type, IGListAdapterUpdateTypeReloadData); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAddingMultipleUpdateListeners_withItemUpdatesAnimated_thatEventsReceived { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener1 = [IGListAdapterUpdateTester new];; + IGListAdapterUpdateTester *listener2 = [IGListAdapterUpdateTester new];; + + [self.adapter addUpdateListener:listener1]; + [self.adapter addUpdateListener:listener2]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + + XCTestExpectation *expectation = genExpectation; + [section.collectionContext performBatchAnimated:YES updates:^(id _Nonnull batchContext) { + [batchContext reloadInSectionController:section atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished) { + XCTAssertEqual(listener1.hits, 1); + XCTAssertEqual(listener1.animated, YES); + XCTAssertEqual(listener1.type, IGListAdapterUpdateTypeItemUpdates); + XCTAssertEqual(listener2.hits, 1); + XCTAssertEqual(listener2.animated, YES); + XCTAssertEqual(listener2.type, IGListAdapterUpdateTypeItemUpdates); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAddingMultipleUpdateListeners_withItemUpdatesNotAnimated_thatEventsReceived { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener1 = [IGListAdapterUpdateTester new];; + IGListAdapterUpdateTester *listener2 = [IGListAdapterUpdateTester new];; + + [self.adapter addUpdateListener:listener1]; + [self.adapter addUpdateListener:listener2]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + IGListSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + + XCTestExpectation *expectation = genExpectation; + [section.collectionContext performBatchAnimated:NO updates:^(id _Nonnull batchContext) { + [batchContext reloadInSectionController:section atIndexes:[NSIndexSet indexSetWithIndex:0]]; + } completion:^(BOOL finished) { + XCTAssertEqual(listener1.hits, 1); + XCTAssertEqual(listener1.animated, NO); + XCTAssertEqual(listener1.type, IGListAdapterUpdateTypeItemUpdates); + XCTAssertEqual(listener2.hits, 1); + XCTAssertEqual(listener2.animated, NO); + XCTAssertEqual(listener2.type, IGListAdapterUpdateTypeItemUpdates); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAddingMultipleUpdateListeners_thenRemovingListener_thatRemainingReceives { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener1 = [IGListAdapterUpdateTester new];; + IGListAdapterUpdateTester *listener2 = [IGListAdapterUpdateTester new];; + + [self.adapter addUpdateListener:listener1]; + [self.adapter addUpdateListener:listener2]; + [self.adapter removeUpdateListener:listener2]; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertEqual(listener1.hits, 1); + XCTAssertEqual(listener1.animated, YES); + XCTAssertEqual(listener1.type, IGListAdapterUpdateTypePerformUpdates); + XCTAssertEqual(listener2.hits, 0); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenAddingUpdateListener_thenListenerReferenceHitsZero_thatListenerReleased { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + IGListAdapterUpdateTester *listener = [IGListAdapterUpdateTester new]; + __weak id weakListener = listener; + [self.adapter addUpdateListener:listener]; + listener = nil; + + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertNil(weakListener); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenModifyingInitialAndFinalAttribute_thatLayoutIsCorrect { + // set up the custom layout + IGListCollectionViewLayout *layout = [[IGListCollectionViewLayout alloc] initWithStickyHeaders:NO topContentInset:0 stretchToEdge:YES]; + self.collectionView.collectionViewLayout = layout; + + IGTestObject *object = genTestObject(@1, @2); + [self setupWithObjects:@ [object]]; + + // set up the section controller + IGTestDelegateController *sectionController = [self.adapter sectionControllerForObject:object]; + sectionController.transitionDelegate = sectionController; + + CGPoint offset = CGPointMake(10, 10); + NSIndexPath *indexPath = genIndexPath(0, 0); + UICollectionViewLayoutAttributes *attribute = [layout layoutAttributesForItemAtIndexPath:indexPath]; + + // set up the custom initial attribute transformation + sectionController.initialAttributesOffset = offset; + UICollectionViewLayoutAttributes *initialAttribute = [layout initialLayoutAttributesForAppearingItemAtIndexPath:indexPath]; + + // set up the custom final attribute transformation + sectionController.finalAttributesOffset = offset; + UICollectionViewLayoutAttributes *finalAttribute = [layout finalLayoutAttributesForDisappearingItemAtIndexPath:indexPath]; + + IGAssertEqualPoint(initialAttribute.center, attribute.center.x + offset.x, attribute.center.y + offset.y); + IGAssertEqualPoint(finalAttribute.center, attribute.center.x + offset.x ,attribute.center.y + offset.y); +} + +- (void)test_whenSwappingCollectionViewsAfterUpdate_thatUpdatePerformedOnTheCorrectCollectionView { + // BEGIN: setup of FIRST adapter+dataSource+collectionView + IGListAdapter *adapter1 = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + + UICollectionView *collectionView1 = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView1]; + adapter1.collectionView = collectionView1; + + IGTestDelegateDataSource *dataSource1 = [IGTestDelegateDataSource new]; + dataSource1.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + adapter1.dataSource = dataSource1; + // END: setup of FIRST adapter+dataSource+collectionView + + // BEGIN: setup of SECOND adapter+dataSource+collectionView + IGListAdapter *adapter2 = [[IGListAdapter alloc] initWithUpdater:[IGListExperimentalAdapterUpdater new] viewController:nil]; + + UICollectionView *collectionView2 = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView2]; + adapter2.collectionView = collectionView2; + + IGTestDelegateDataSource *dataSource2 = [IGTestDelegateDataSource new]; + dataSource2.objects = @[ + genTestObject(@3, @1) + ]; + adapter2.dataSource = dataSource2; + // END: setup of SECOND adapter+dataSource+collectionView + + // delete the last-most section from the FIRST dataSource + dataSource1.objects = @[ + genTestObject(@1, @1) + ]; + + XCTestExpectation *expectation = genExpectation; + [adapter1 performUpdatesAnimated:YES completion:^(BOOL finished) { + [expectation fulfill]; + }]; + + // simulate a collectionView swap (e.g. cell reuse) immediately after an async update is queued + adapter1.collectionView = collectionView2; + adapter2.collectionView = collectionView1; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenCollectionViewBecomesNilDuringPerformUpdates_thatStateCleanedCorrectly { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + // perform update on listAdapter + XCTestExpectation *expectation1 = genExpectation; + [self.adapter performUpdatesAnimated:NO completion:^(BOOL finished) { + [expectation1 fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; + + // update the underlying contents before performing another update + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + // perform update, but set the listAdapter's collectionView to nil during the update + XCTestExpectation *expectation2 = genExpectation; + [self.adapter performUpdatesAnimated:NO completion:^(BOOL finished) { + [expectation2 fulfill]; + }]; + self.adapter.collectionView = nil; + [self waitForExpectationsWithTimeout:30 handler:nil]; + + // add a new collectionView to the listAdapter + UICollectionView *collectionView2 = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView2]; + self.adapter.collectionView = collectionView2; + + // update the underlying contents before performing update + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@3, @1) + ]; + + // perform update on listAdapter (now with a non-nil collectionView) + XCTestExpectation *expectation3 = genExpectation; + [self.adapter performUpdatesAnimated:NO completion:^(BOOL finished) { + [expectation3 fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenCollectionViewBecomesNilDuringReloadData_thatStateCleanedCorrectly { + [self setupWithObjects:@[ + genTestObject(@1, @1) + ]]; + + // reload data on listAdapter + XCTestExpectation *expectation1 = genExpectation; + [self.adapter reloadDataWithCompletion:^(BOOL finished) { + [expectation1 fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; + + // update the underlying contents before reloading again + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1) + ]; + + // reload data, but set the listAdapter's collectionView to nil during the update + XCTestExpectation *expectation2 = genExpectation; + [self.adapter reloadDataWithCompletion:^(BOOL finished) { + [expectation2 fulfill]; + }]; + self.adapter.collectionView = nil; + [self waitForExpectationsWithTimeout:30 handler:nil]; + + // add a new collectionView to the listAdapter + UICollectionView *collectionView2 = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView2]; + self.adapter.collectionView = collectionView2; + self.dataSource.objects = @[ + genTestObject(@1, @1), + genTestObject(@2, @1), + genTestObject(@3, @1) + ]; + + // reload data on listAdapter (now with a non-nil collectionView) + XCTestExpectation *expectation3 = genExpectation; + [self.adapter reloadDataWithCompletion:^(BOOL finished) { + [expectation3 fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +@end diff --git a/Tests/IGListExperimentalAdapterUpdaterTests.m b/Tests/IGListExperimentalAdapterUpdaterTests.m new file mode 100644 index 00000000..16e98e2a --- /dev/null +++ b/Tests/IGListExperimentalAdapterUpdaterTests.m @@ -0,0 +1,1069 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +#import + +#import "IGListAdapterUpdaterHelpers.h" +#import "IGListExperimentalAdapterUpdaterInternal.h" +#import "IGListMoveIndexInternal.h" +#import "IGListTestUICollectionViewDataSource.h" +#import "IGTestObject.h" + +#define genExpectation [self expectationWithDescription:NSStringFromSelector(_cmd)] +#define waitExpectation [self waitForExpectationsWithTimeout:30 handler:nil] +#define genToBlock ^NSArray *{ return to; } + +@interface IGListExperimentalAdapterUpdaterTests : XCTestCase + +@property (nonatomic, strong) UIWindow *window; +@property (nonatomic, strong) UICollectionView *collectionView; +@property (nonatomic, strong) IGListTestUICollectionViewDataSource *dataSource; +@property (nonatomic, strong) IGListExperimentalAdapterUpdater *updater; +@property (nonatomic, strong) IGListObjectTransitionBlock updateBlock; + +@end + +@implementation IGListExperimentalAdapterUpdaterTests + +- (IGListCollectionViewBlock)collectionViewBlock { + return ^UICollectionView *{ return self.collectionView; }; +} + +- (void)setUp { + [super setUp]; + + self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + + UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; + self.collectionView = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:layout]; + + [self.window addSubview:self.collectionView]; + + self.dataSource = [[IGListTestUICollectionViewDataSource alloc] initWithCollectionView:self.collectionView]; + self.updater = [IGListExperimentalAdapterUpdater new]; + __weak __typeof__(self) weakSelf = self; + self.updateBlock = ^(NSArray *obj) { + weakSelf.dataSource.sections = obj; + }; +} + +- (void)tearDown { + [super tearDown]; + + self.collectionView = nil; + self.dataSource = nil; + self.updater = nil; +} + +- (void)test_whenUpdatingWithNil_thatUpdaterHasNoChanges { + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:nil toObjectsBlock:nil animated:YES objectTransitionBlock:self.updateBlock completion:nil]; + XCTAssertFalse([self.updater hasChanges]); +} + +- (void)test_whenUpdatingtoObjects_thatUpdaterHasChanges { + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:nil toObjectsBlock:^NSArray *{return @[@0];} animated:YES objectTransitionBlock:self.updateBlock completion:nil]; + XCTAssertTrue([self.updater hasChanges]); +} + +- (void)test_whenUpdatingfromObjects_thatUpdaterHasChanges { + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:@[@0] toObjectsBlock:^NSArray *{return nil;} animated:YES objectTransitionBlock:self.updateBlock completion:nil]; + XCTAssertTrue([self.updater hasChanges]); +} + +- (void)test_whenUpdatingtoObjects_withfromObjects_thatUpdaterHasChanges { + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:@[@0] toObjectsBlock:^NSArray *{return @[@1];} animated:YES objectTransitionBlock:self.updateBlock completion:nil]; + XCTAssertTrue([self.updater hasChanges]); +} + +- (void)test_whenCleaningUpState_withChanges_thatUpdaterHasNoChanges { + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:nil toObjectsBlock:^NSArray *{return @[@0];} animated:YES objectTransitionBlock:self.updateBlock completion:nil]; + XCTAssertTrue([self.updater hasChanges]); + [self.updater cleanStateBeforeUpdates]; + XCTAssertFalse([self.updater hasChanges]); +} + +- (void)test_whenReloadingData_thatCollectionViewUpdates { + self.dataSource.sections = @[[IGSectionObject sectionWithObjects:@[]]]; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 1); + self.dataSource.sections = @[]; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 0); +} + +- (void)test_whenReloadingDataWithNilDataSourceBefore_thatCollectionViewNotCrash { + self.dataSource.sections = @[[IGSectionObject sectionWithObjects:@[@1]], [IGSectionObject sectionWithObjects:@[@2]]]; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 2); + + self.collectionView.dataSource = nil; + self.dataSource.sections = @[]; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 1); // Setting collectionView's dataSource to nil would yield a single section by default. +} + +- (void)test_whenInsertingSection_thatCollectionViewUpdates { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[]] + ]; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]] + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 1); + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenDeletingSection_thatCollectionViewUpdates { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]] + ]; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]] + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 2); + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenInsertingSection_withItemChanges_thatCollectionViewUpdates { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[@0]] + ]; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[@0, @1]], + [IGSectionObject sectionWithObjects:@[@0, @1]] + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenInsertingSection_withDeletedSection_thatCollectionViewUpdates { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[@0, @1, @2]], + [IGSectionObject sectionWithObjects:@[]] + ]; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[@1, @1]], + [IGSectionObject sectionWithObjects:@[@0]], + [IGSectionObject sectionWithObjects:@[@0, @2, @3]] + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenReloadingSections_thatCollectionViewUpdates { + self.dataSource.sections = @[ + [IGSectionObject sectionWithObjects:@[@0, @1]], + [IGSectionObject sectionWithObjects:@[@0, @1]] + ]; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + + self.dataSource.sections = @[ + [IGSectionObject sectionWithObjects:@[@0, @1, @2]], + [IGSectionObject sectionWithObjects:@[@0, @1]] + ]; + [self.updater reloadCollectionView:self.collectionView sections:[NSIndexSet indexSetWithIndex:0]]; + + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); +} + +- (void)test_whenCollectionViewNeedsLayout_thatPerformBatchUpdateWorks { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]] + ]; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]] + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + + // the collection view has been setup with 1 section and now needs layout + // calling performBatchUpdates: on a collection view needing layout will force layout + // we need to ensure that our data source is not changed until the update block is executed + [self.collectionView setNeedsLayout]; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 1); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenUpdatesAreReentrant_thatUpdatesExecuteSerially { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[]], + ]; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]], + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + + __block NSInteger completionCounter = 0; + + XCTestExpectation *expectation1 = genExpectation; + void (^preUpdateBlock)(void) = ^{ + NSArray *(^anotherTo)(void) = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]] + ]; + }; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:anotherTo animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + completionCounter++; + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual(completionCounter, 2); + [expectation1 fulfill]; + }]; + }; + + XCTestExpectation *expectation2 = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:YES objectTransitionBlock:^(NSArray *toObjects) { + // executing this block within the updater is just before performBatchUpdates: are applied + // should be able to queue another update here, similar to an update being queued between it beginning and executing + // the performBatchUpdates: block + preUpdateBlock(); + + self.dataSource.sections = toObjects; + } completion:^(BOOL finished) { + completionCounter++; + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual(completionCounter, 1); + [expectation2 fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenQueuingItemUpdates_thatUpdaterHasChanges { + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] animated:YES itemUpdates:^{} completion:nil]; + XCTAssertTrue([self.updater hasChanges]); +} + +- (void)test_whenOnlyQueueingItemUpdates_thatUpdateBlockExecutes { + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] animated:YES itemUpdates:^{ + // expectation should be triggered. test failure is a timeout + [expectation fulfill]; + } completion:nil]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenQueueingItemUpdates_withBatchUpdate_thatItemUpdateBlockExecutes { + __block BOOL itemUpdateBlockExecuted = NO; + __block BOOL sectionUpdateBlockExecuted = NO; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] + fromObjects:nil + toObjectsBlock:^NSArray *{return @[[IGSectionObject sectionWithObjects:@[@1]]];} + animated:YES objectTransitionBlock:^(NSArray * toObjects) { + self.dataSource.sections = toObjects; + sectionUpdateBlockExecuted = YES; + } + completion:nil]; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] animated:YES itemUpdates:^{ + itemUpdateBlockExecuted = YES; + } completion:^(BOOL finished) { + // test in the item completion block that the SECTION operations have been performed + XCTAssertEqual([self.collectionView numberOfSections], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + XCTAssertTrue(itemUpdateBlockExecuted); + XCTAssertTrue(sectionUpdateBlockExecuted); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenItemsMoveAndUpdate_thatCollectionViewWorks { + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]], + [IGSectionObject sectionWithObjects:@[]], + ]; + + // change the number of items in the section, which a move would be unable to handle and would throw + // keep the same pointers so that the objects are equal + [from[2] setObjects:@[@1]]; + [from[0] setObjects:@[@1, @1]]; + [from[1] setObjects:@[@1, @1, @1]]; + + IGListToObjectBlock to = ^NSArray *{ + // rearrange the modified objects + return @[ + from[2], + from[0], + from[1] + ]; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + + // without moves as inserts, we would assert b/c the # of items in each section changes + self.updater.sectionMovesAsDeletesInserts = YES; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:to animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 3); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenConvertingReloads_withoutChanges_thatOriginalIndexUsed { + NSArray *from = @[]; + NSArray *to = @[]; + IGListIndexSetResult *result = IGListDiff(from, to, IGListDiffEquality); + NSMutableIndexSet *reloads = [result.updates mutableCopy]; + [reloads addIndex:2]; + NSMutableIndexSet *deletes = [result.deletes mutableCopy]; + NSMutableIndexSet *inserts = [result.inserts mutableCopy]; + IGListConvertReloadToDeleteInsert(reloads, deletes, inserts, result, from); + XCTAssertEqual(reloads.count, 0); + XCTAssertEqual(deletes.count, 1); + XCTAssertEqual(inserts.count, 1); + XCTAssertTrue([deletes containsIndex:2]); + XCTAssertTrue([inserts containsIndex:2]); +} + +- (void)test_whenConvertingReloads_withChanges_thatIndexMoves { + NSArray *from = @[@1, @2, @3]; + NSArray *to = @[@3, @2, @1]; + IGListIndexSetResult *result = IGListDiff(from, to, IGListDiffEquality); + NSMutableIndexSet *reloads = [result.updates mutableCopy]; + [reloads addIndex:2]; + NSMutableIndexSet *deletes = [result.deletes mutableCopy]; + NSMutableIndexSet *inserts = [result.inserts mutableCopy]; + IGListConvertReloadToDeleteInsert(reloads, deletes, inserts, result, from); + XCTAssertEqual(reloads.count, 0); + XCTAssertEqual(deletes.count, 1); + XCTAssertEqual(inserts.count, 1); + XCTAssertTrue([deletes containsIndex:2]); + XCTAssertTrue([inserts containsIndex:0]); +} + +- (void)test_whenReloadingSection_whenSectionRemoved_thatConvertMethodCorrects { + NSArray *from = @[@"a", @"b", @"c"]; + NSArray *to = @[@"a", @"c"]; + IGListIndexSetResult *result = IGListDiff(from, to, IGListDiffEquality); + NSMutableIndexSet *reloads = [NSMutableIndexSet indexSetWithIndex:1]; + NSMutableIndexSet *deletes = [NSMutableIndexSet new]; + NSMutableIndexSet *inserts = [NSMutableIndexSet new]; + IGListConvertReloadToDeleteInsert(reloads, deletes, inserts, result, from); + XCTAssertEqual(reloads.count, 0); + XCTAssertEqual(deletes.count, 0); + XCTAssertEqual(inserts.count, 0); +} + +- (void)test_whenReloadingData_withNilCollectionView_thatDelegateFinishesWithoutUpdates { + id mockDelegate = [OCMockObject mockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + id compilerFriendlyNil = nil; + [[mockDelegate expect] listAdapterUpdater:self.updater didFinishWithoutUpdatesWithCollectionView:nil]; + [self.updater performReloadDataWithCollectionViewBlock:^UICollectionView *{ return compilerFriendlyNil; }]; + [mockDelegate verify]; +} + +- (void)test_whenPerformingUpdates_withNilCollectionView_thatDelegateFinishesWithoutUpdates { + id mockDelegate = [OCMockObject mockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + id compilerFriendlyNil = nil; + [[mockDelegate expect] listAdapterUpdater:self.updater didFinishWithoutUpdatesWithCollectionView:nil]; + [self.updater performBatchUpdatesWithCollectionViewBlock:^UICollectionView *{ return compilerFriendlyNil; }]; + [mockDelegate verify]; +} + +- (void)test_whenCallingReloadData_withUICollectionViewFlowLayout_withEstimatedSize_thatSectionItemCountsCorrect { + UICollectionViewFlowLayout *layout = [UICollectionViewFlowLayout new]; + // setting the estimated size of a layout causes UICollectionView to requery layout attributes during reloadData + // this becomes out of sync with the data source if the section/item count changes + layout.estimatedItemSize = CGSizeMake(100, 10); + + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:CGRectMake(0, 0, 100, 100) collectionViewLayout:layout]; + IGListTestUICollectionViewDataSource *dataSource = [[IGListTestUICollectionViewDataSource alloc] initWithCollectionView:collectionView]; + + // 2 sections, 1 item in 1st, 4 items in 2nd + dataSource.sections = @[ + [IGSectionObject sectionWithObjects:@[@1]], + [IGSectionObject sectionWithObjects:@[@1, @2, @3, @4]] + ]; + + // assert the initial state of the collection view WITHOUT any layoutSubviews or anything + XCTAssertEqual([collectionView numberOfSections], 2); + XCTAssertEqual([collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([collectionView numberOfItemsInSection:1], 4); + + dataSource.sections = @[ + [IGSectionObject sectionWithObjects:@[@1]], + ]; + + IGListExperimentalAdapterUpdater *updater = [IGListExperimentalAdapterUpdater new]; + [updater performReloadDataWithCollectionViewBlock:^UICollectionView *{ return collectionView; }]; + + XCTAssertEqual([collectionView numberOfSections], 1); + XCTAssertEqual([collectionView numberOfItemsInSection:0], 1); + + dataSource.sections = @[ + [IGSectionObject sectionWithObjects:@[@1]], + [IGSectionObject sectionWithObjects:@[@1, @2, @3, @4]] + ]; + [updater performReloadDataWithCollectionViewBlock:^UICollectionView *{ return collectionView; }]; + + XCTAssertEqual([collectionView numberOfSections], 2); + XCTAssertEqual([collectionView numberOfItemsInSection:0], 1); + XCTAssertEqual([collectionView numberOfItemsInSection:1], 4); +} + +- (void)test_whenCollectionViewNotInWindow_andBackgroundReloadFlag_isSetNO_diffHappens { + self.updater.allowsBackgroundReloading = NO; + [self.collectionView removeFromSuperview]; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + NSArray *to = @[[IGSectionObject sectionWithObjects:@[]]]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:self.dataSource.sections toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + return result.inserts.firstIndex == 0 && result.moves.count == 0 && result.updates.count == 0 && result.deletes.count == 0; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:OCMOCK_ANY collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:self.dataSource.sections toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenCollectionViewNotInWindow_andBackgroundReloadFlag_isDefaultYES_fallbackToReload { + [self.collectionView removeFromSuperview]; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willReloadDataWithCollectionView:self.collectionView isFallbackReload:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater didReloadDataWithCollectionView:self.collectionView isFallbackReload:YES]; + + XCTestExpectation *expectation = genExpectation; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]] + ]; + }; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:self.dataSource.sections toObjectsBlock:to animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenCollectionViewNotInWindow_andBackgroundReloadFlag_isDefaultYES_andDataSourceWasSetToNilBefore_fallbackToReload { + [self.collectionView removeFromSuperview]; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willReloadDataWithCollectionView:self.collectionView isFallbackReload:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater didReloadDataWithCollectionView:self.collectionView isFallbackReload:YES]; + + XCTestExpectation *expectation = genExpectation; + IGListToObjectBlock to = ^NSArray *{ + return @[ + [IGSectionObject sectionWithObjects:@[]] + ]; + }; + self.collectionView.dataSource = nil; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:self.dataSource.sections toObjectsBlock:to animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenReloadBatchedWithUpdate_thatCompletionBlockStillExecuted { + IGSectionObject *object = [IGSectionObject sectionWithObjects:@[@0, @1, @2]]; + self.dataSource.sections = @[object]; + + __block BOOL reloadDataCompletionExecuted = NO; + [self.updater reloadDataWithCollectionViewBlock:[self collectionViewBlock] reloadUpdateBlock:^{} completion:^(BOOL finished) { + reloadDataCompletionExecuted = YES; + }]; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] animated:YES itemUpdates:^{ + object.objects = @[@2, @1, @4, @5]; + [self.updater insertItemsIntoCollectionView:self.collectionView indexPaths:@[ + [NSIndexPath indexPathForItem:2 inSection:0], + [NSIndexPath indexPathForItem:3 inSection:0], + ]]; + [self.updater deleteItemsFromCollectionView:self.collectionView indexPaths:@[ + [NSIndexPath indexPathForItem:0 inSection:0], + ]]; + [self.updater moveItemInCollectionView:self.collectionView + fromIndexPath:[NSIndexPath indexPathForItem:2 inSection:0] + toIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + } completion:^(BOOL finished) { + XCTAssertTrue(reloadDataCompletionExecuted); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenNotInViewHierarchy_thatUpdatesStillExecuteBlocks { + [self.collectionView removeFromSuperview]; + + IGSectionObject *object = [IGSectionObject sectionWithObjects:@[@0, @1, @2]]; + self.dataSource.sections = @[object]; + + __block BOOL objectTransitionBlockExecuted = NO; + __block BOOL completionBlockExecuted = NO; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] + fromObjects:self.dataSource.sections + toObjectsBlock:^NSArray *{return self.dataSource.sections;} + animated:YES + objectTransitionBlock:^(NSArray *toObjects) { + objectTransitionBlockExecuted = YES; + } + completion:^(BOOL finished) { + completionBlockExecuted = YES; + }]; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] animated:YES itemUpdates:^{ + object.objects = @[@2, @1, @4, @5]; + [self.updater insertItemsIntoCollectionView:self.collectionView indexPaths:@[ + [NSIndexPath indexPathForItem:2 inSection:0], + [NSIndexPath indexPathForItem:3 inSection:0], + ]]; + [self.updater deleteItemsFromCollectionView:self.collectionView indexPaths:@[ + [NSIndexPath indexPathForItem:0 inSection:0], + ]]; + [self.updater moveItemInCollectionView:self.collectionView + fromIndexPath:[NSIndexPath indexPathForItem:2 inSection:0] + toIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + } completion:^(BOOL finished) { + XCTAssertTrue(objectTransitionBlockExecuted); + XCTAssertTrue(completionBlockExecuted); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenNotBatchUpdate_thatDelegateEventsSent { + IGSectionObject *object = [IGSectionObject sectionWithObjects:@[@0, @1, @2]]; + self.dataSource.sections = @[object]; + [self.collectionView reloadData]; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willDeleteIndexPaths:OCMOCK_ANY collectionView:self.collectionView]; + [[mockDelegate expect] listAdapterUpdater:self.updater willInsertIndexPaths:OCMOCK_ANY collectionView:self.collectionView]; + [[mockDelegate expect] listAdapterUpdater:self.updater + willMoveFromIndexPath:OCMOCK_ANY + toIndexPath:OCMOCK_ANY + collectionView:self.collectionView]; + [[mockDelegate expect] listAdapterUpdater:self.updater willReloadIndexPaths:OCMOCK_ANY collectionView:self.collectionView]; + + // This code is of no use, but it will let UICollectionView synchronize number of items, + // so it will not crash in following updates. https://stackoverflow.com/a/46751421/2977647 + [self.collectionView numberOfItemsInSection:0]; + + object.objects = @[@1, @2]; + [self.updater deleteItemsFromCollectionView:self.collectionView indexPaths:@[ + [NSIndexPath indexPathForItem:0 inSection:0], + ]]; + object.objects = @[@1, @2, @4, @5]; + [self.updater insertItemsIntoCollectionView:self.collectionView indexPaths:@[ + [NSIndexPath indexPathForItem:2 inSection:0], + [NSIndexPath indexPathForItem:3 inSection:0], + ]]; + object.objects = @[@2, @1, @4, @5]; + [self.updater moveItemInCollectionView:self.collectionView + fromIndexPath:[NSIndexPath indexPathForItem:2 inSection:0] + toIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + + [self.updater reloadItemInCollectionView:self.collectionView + fromIndexPath:[NSIndexPath indexPathForItem:0 inSection:0] + toIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + [mockDelegate verify]; +} + +- (void)test_whenObjectIdentifiersCollide_withDifferentTypes_thatLookupReturnsNil { + id testObject = [[IGTestObject alloc] initWithKey:@"foo" value:@"bar"]; + id collision = @"foo"; + XCTAssertEqual(collision, [testObject diffIdentifier]); + + IGListExperimentalAdapterUpdater *updater = [IGListExperimentalAdapterUpdater new]; + + // mimic internal map setup in IGListAdapter + NSPointerFunctions *keyFunctions = [updater objectLookupPointerFunctions]; + NSPointerFunctions *valueFunctions = [NSPointerFunctions pointerFunctionsWithOptions:NSPointerFunctionsStrongMemory]; + NSMapTable *table = [[NSMapTable alloc] initWithKeyPointerFunctions:keyFunctions valuePointerFunctions:valueFunctions capacity:0]; + + [table setObject:@1 forKey:testObject]; + XCTAssertNotNil([table objectForKey:testObject]); + XCTAssertNil([table objectForKey:collision]); +} + +- (void)test_whenReloadIsCalledWithSameItemCount_deleteInsertSectionHappen { + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet indexSetWithIndex:0] + deleteSections:[NSIndexSet indexSetWithIndex:0] + moveSections:[NSSet new] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id"]]; + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@2] identifier:@"id"]]; + self.dataSource.sections = from; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + return result.inserts.count == 0 && result.deletes.count == 0 && result.moves.count == 0 && result.updates.firstIndex == 0; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenPerformUpdates_dataSourceWasSetToNil_shouldNotCrash { + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id1"]]; + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@2] identifier:@"id1"], + [IGSectionObject sectionWithObjects:@[@22] identifier:@"id2"]]; + self.dataSource.sections = from; + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater didFinishWithoutUpdatesWithCollectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + // Manually set the data source to be nil. + self->_collectionView.dataSource = nil; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:^(NSArray * _Nonnull toObjects) { + } completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenPerformIndexPathUpdates_reloadingTheSameIndexPathMultipleTimes_shouldNotCrash { + // Set up data + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id"]]; + self.dataSource.sections = from; + + // Mock delegate to confirm update did work + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:OCMOCK_ANY collectionView:self.collectionView]; + + // Expectation to wait for performUpdate to finish + XCTestExpectation *expectation = genExpectation; + + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0]; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] + animated:NO + itemUpdates:^{ + [self.updater reloadItemInCollectionView:self.collectionView fromIndexPath:indexPath toIndexPath:indexPath]; + [self.updater reloadItemInCollectionView:self.collectionView fromIndexPath:indexPath toIndexPath:indexPath]; + } + completion:^(BOOL finished) { + [expectation fulfill]; + }]; + + waitExpectation; + + [mockDelegate verify]; +} + +- (void)test_whenPerformingUpdatesMultipleTimesInARow_thenUpdateWorks { + NSArray *objects1 = @[ + [IGSectionObject sectionWithObjects:@[@0]] + ]; + NSArray *objects2 = @[ + [IGSectionObject sectionWithObjects:@[@0, @1]], + [IGSectionObject sectionWithObjects:@[@0, @1]] + ]; + NSArray *objects3 = @[ + [IGSectionObject sectionWithObjects:@[@0, @1]], + [IGSectionObject sectionWithObjects:@[@0, @1]], + [IGSectionObject sectionWithObjects:@[@0, @1]] + ]; + IGListToObjectBlock toObjectsBlock2 = ^NSArray *{ + return objects2; + }; + IGListToObjectBlock toObjectsBlock3 = ^NSArray *{ + return objects3; + }; + + self.dataSource.sections = objects1; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + + XCTestExpectation *expectation = genExpectation; + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:objects1 toObjectsBlock:toObjectsBlock2 animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + XCTAssertEqual([self.collectionView numberOfSections], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:objects2 toObjectsBlock:toObjectsBlock3 animated:YES objectTransitionBlock:self.updateBlock completion:^(BOOL finished2) { + XCTAssertEqual([self.collectionView numberOfSections], 3); + XCTAssertEqual([self.collectionView numberOfItemsInSection:0], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:1], 2); + XCTAssertEqual([self.collectionView numberOfItemsInSection:2], 2); + [expectation fulfill]; + }]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenPerformingUpdate_thatCallsDiffingDelegate { + self.updater.experiments |= IGListExperimentBackgroundDiffing; + + NSArray *from = @[ + [IGSectionObject sectionWithObjects:@[] identifier:@"0"] + ]; + NSArray *to = @[ + [IGSectionObject sectionWithObjects:@[] identifier:@"0"], + [IGSectionObject sectionWithObjects:@[] identifier:@"1"] + ]; + IGListToObjectBlock toBlock = ^NSArray *{ + return to; + }; + + self.dataSource.sections = from; + [self.updater performReloadDataWithCollectionViewBlock:[self collectionViewBlock]]; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willDiffFromObjects:from toObjects:to]; + [[mockDelegate expect] listAdapterUpdater:self.updater didDiffWithResults:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + return [result.inserts isEqualToIndexSet:[NSIndexSet indexSetWithIndex:1]] + && result.deletes.count == 0 + && result.updates.count == 0 + && result.moves.count == 0; + }] onBackgroundThread:YES]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:toBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + + +# pragma mark - preferItemReloadsFroSectionReloads + +- (void)test_whenReloadIsCalledWithSameItemCount_andPreferItemReload_updateIndexPathsHappen { + self.updater.preferItemReloadsForSectionReloads = YES; + + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet new] + deleteSections:[NSIndexSet new] + moveSections:[NSSet new] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[[NSIndexPath indexPathForItem:0 inSection:0]] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id"]]; + // Update the items + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@2] identifier:@"id"]]; + self.dataSource.sections = from; + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + return result.inserts.count == 0 && result.deletes.count == 0 && result.moves.count == 0 && result.updates.firstIndex == 0; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenReloadIsCalledWithDifferentItemCount_andPreferItemReload_deleteInsertSectionHappen { + self.updater.preferItemReloadsForSectionReloads = YES; + + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet indexSetWithIndex:0] + deleteSections:[NSIndexSet indexSetWithIndex:0] + moveSections:[NSSet new] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id"]]; + // more items in the section + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@1, @2] identifier:@"id"]]; + self.dataSource.sections = from; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + return result.inserts.count == 0 && result.deletes.count == 0 && result.moves.count == 0 && result.updates.firstIndex == 0; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenReloadIsCalledWithSectionMoveAndUpdate_andPreferItemReload_deleteInsertMoveHappens { + self.updater.preferItemReloadsForSectionReloads = YES; + + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet indexSetWithIndex:0] + deleteSections:[NSIndexSet indexSetWithIndex:1] + moveSections:[NSSet setWithArray:@[[[IGListMoveIndex alloc] initWithFrom:0 to:1]]] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id1"], + [IGSectionObject sectionWithObjects:@[@2] identifier:@"id2"]]; + // move section, and also update the item for "id2" + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@22] identifier:@"id2"], + [IGSectionObject sectionWithObjects:@[@1] identifier:@"id1"]]; + self.dataSource.sections = from; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + if (result.inserts.count != 0 || result.deletes.count != 0) { + return NO; + } + // Make sure we note that index 1 is updated (id2 from @[@2] -> @[@22], "id1" moved from section 0 -> 1, and "id2" moved from section 1 -> 0 + return result.updates.firstIndex == 1 && result.moves.count == 2 && [result.moves containsObject:[[IGListMoveIndex alloc] initWithFrom:0 to:1]] && [result.moves containsObject:[[IGListMoveIndex alloc] initWithFrom:1 to:0]]; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenReloadIsCalledWithSectionMoveAndUpdate_withDifferentSectionLength_andPreferItemReload_deleteInsertMoveHappens { + self.updater.preferItemReloadsForSectionReloads = YES; + + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet indexSetWithIndex:0] + deleteSections:[NSIndexSet indexSetWithIndex:1] + moveSections:[NSSet setWithArray:@[[[IGListMoveIndex alloc] initWithFrom:0 to:1]]] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1, @2, @3] identifier:@"id1"], + [IGSectionObject sectionWithObjects:@[@2] identifier:@"id2"]]; + // move section, and also update the item for "id2" + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@22] identifier:@"id2"], + [IGSectionObject sectionWithObjects:@[@1, @2, @3] identifier:@"id1"]]; + self.dataSource.sections = from; + + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + if (result.inserts.count != 0 || result.deletes.count != 0) { + return NO; + } + // Make sure we note that index 1 is updated (id2 from @[@2] -> @[@22], "id1" moved from section 0 -> 1, and "id2" moved from section 1 -> 0 + return result.updates.firstIndex == 1 && result.moves.count == 2 && [result.moves containsObject:[[IGListMoveIndex alloc] initWithFrom:0 to:1]] && [result.moves containsObject:[[IGListMoveIndex alloc] initWithFrom:1 to:0]]; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + + +- (void)test_whenReloadIsCalledWithSectionMoveAndUpdate_withThreeSections_deleteInsertMoveHappens { + self.updater.preferItemReloadsForSectionReloads = YES; + + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet indexSetWithIndex:0] + deleteSections:[NSIndexSet indexSetWithIndex:1] + moveSections:[NSSet setWithArray:@[[[IGListMoveIndex alloc] initWithFrom:0 to:1]]] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id1"], + [IGSectionObject sectionWithObjects:@[@2] identifier:@"id2"], + [IGSectionObject sectionWithObjects:@[@3] identifier:@"id3"]]; + // move section, and also update the items for "id2" + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@22, @23] identifier:@"id2"], + [IGSectionObject sectionWithObjects:@[@1] identifier:@"id1"], + [IGSectionObject sectionWithObjects:@[@3] identifier:@"id3"]]; + self.dataSource.sections = from; + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + if (result.inserts.count != 0 || result.deletes.count != 0) { + return NO; + } + // Make sure we note that index 1 is updated (id2 from @[@2] -> @[@22], "id1" moved from section 0 -> 1, "id2" moved from section 1 -> 0 + return result.updates.firstIndex == 1 && result.moves.count == 2 && [result.moves containsObject:[[IGListMoveIndex alloc] initWithFrom:0 to:1]] && [result.moves containsObject:[[IGListMoveIndex alloc] initWithFrom:1 to:0]]; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +- (void)test_whenReloadIsCalledWithSectionInsertAndUpdate_andPreferItemReload_noItemReloads { + self.updater.preferItemReloadsForSectionReloads = YES; + + IGListBatchUpdateData *expectedBatchUpdateData = [[IGListBatchUpdateData alloc] initWithInsertSections:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, 2)] + deleteSections:[NSIndexSet indexSetWithIndex:0] + moveSections:[NSSet new] + insertIndexPaths:@[] + deleteIndexPaths:@[] + updateIndexPaths:@[] + moveIndexPaths:@[]]; + NSArray *from = @[[IGSectionObject sectionWithObjects:@[@1] identifier:@"id1"]]; + NSArray *to = @[[IGSectionObject sectionWithObjects:@[@2] identifier:@"id1"], + [IGSectionObject sectionWithObjects:@[@22] identifier:@"id2"]]; + self.dataSource.sections = from; + id mockDelegate = [OCMockObject niceMockForProtocol:@protocol(IGListAdapterUpdaterDelegate)]; + self.updater.delegate = mockDelegate; + [mockDelegate setExpectationOrderMatters:YES]; + [[mockDelegate expect] listAdapterUpdater:self.updater willPerformBatchUpdatesWithCollectionView:self.collectionView fromObjects:from toObjects:to listIndexSetResult:[OCMArg checkWithBlock:^BOOL(IGListIndexSetResult *result) { + if (result.deletes.count != 0 || result.moves.count != 0) { + return NO; + } + // Make sure we note that index 1 is updated (id1 from @[@1] -> @[@2]), and "id2" was inserted at index 1 + return result.updates.firstIndex == 0 && result.inserts.firstIndex == 1; + }] animated:NO]; + [[mockDelegate expect] listAdapterUpdater:self.updater didPerformBatchUpdates:expectedBatchUpdateData collectionView:self.collectionView]; + + XCTestExpectation *expectation = genExpectation; + + [self.updater performUpdateWithCollectionViewBlock:[self collectionViewBlock] fromObjects:from toObjectsBlock:genToBlock animated:NO objectTransitionBlock:self.updateBlock completion:^(BOOL finished) { + [expectation fulfill]; + }]; + waitExpectation; + [mockDelegate verify]; +} + +@end