From 6e173c227f7d1071a43a7b2e163ba446ac201e0a Mon Sep 17 00:00:00 2001 From: Nickolay Tarbayev Date: Sat, 14 Apr 2018 11:38:56 +0200 Subject: [PATCH] Implements experimental diff with optimized moves calculation to produce only minimal required moves --- CHANGELOG.md | 2 + IGListKit.xcodeproj/project.pbxproj | 12 + Source/Common/IGListDiff.mm | 128 +++++++++- Source/Common/IGListExperiments.h | 2 + .../IGListDiffExperimentOptimizedMovesTests.m | 235 ++++++++++++++++++ 5 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 Tests/IGListDiffExperimentOptimizedMovesTests.m diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a3823a8..1a5e23a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag ### Enhancements +- Implemented experimental diff with optimized moves calculation to produce only minimal required moves, enabled by `IGListExperimentOptimizedMoves` option. [Nickolay Tarbayev](https://github.com/tarbayev) [(#1139)](https://github.com/Instagram/IGListKit/pull/1139) + - Add support for UICollectionView's interactive reordering in iOS 9+. Updates include `-[IGListSectionController canMoveItemAtIndex:]` to enable the behavior, `-[IGListSectionController moveObjectFromIndex:toIndex:]` called when items within a section controller were moved through reordering, `-[IGListAdapterDataSource listAdapter:moveObject:from:to]` called when section controllers themselves were reordered (only possible when all section controllers contain exactly 1 object), and `-[IGListUpdatingDelegate moveSectionInCollectionView:fromIndex:toIndex]` to enable custom updaters to conform to the reordering behavior. The update also includes two new examples `ReorderableSectionController` and `ReorderableStackedViewController` to demonstrate how to enable interactive reordering in your client app. [Jared Verdi](https://github.com/jverdi) [(#976)](https://github.com/Instagram/IGListKit/pull/976) - 5x improvement to diffing performance when result is only inserts or deletes. [Ryan Nystrom](https://github.com/rnystrom) [(tbd)](tbd) diff --git a/IGListKit.xcodeproj/project.pbxproj b/IGListKit.xcodeproj/project.pbxproj index 55cab0351..b24b96de0 100644 --- a/IGListKit.xcodeproj/project.pbxproj +++ b/IGListKit.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 0179E635207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0179E634207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m */; }; + 0179E636207FA8F30082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0179E634207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m */; }; + 0179E637207FA8F30082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0179E634207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m */; }; + 01A3FEE120FFB30100E91657 /* GameplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01A3FEE020FFB30000E91657 /* GameplayKit.framework */; }; 0B3B92DA1E08D7F5008390ED /* IGListKit.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B3B928B1E08D7F5008390ED /* IGListKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; 0B3B92DB1E08D7F5008390ED /* IGListKit.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B3B928B1E08D7F5008390ED /* IGListKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; 0B3B92F61E08D7F5008390ED /* IGListAdapter.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B3B929A1E08D7F5008390ED /* IGListAdapter.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -407,6 +411,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0179E634207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IGListDiffExperimentOptimizedMovesTests.m; sourceTree = ""; }; + 01A3FEE020FFB30000E91657 /* GameplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GameplayKit.framework; path = System/Library/Frameworks/GameplayKit.framework; sourceTree = SDKROOT; }; 08F0B0FD0690F4FC46DDF21B /* Pods-IGListKit-tvOSTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IGListKit-tvOSTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-IGListKit-tvOSTests/Pods-IGListKit-tvOSTests.release.xcconfig"; sourceTree = ""; }; 0B3B928B1E08D7F5008390ED /* IGListKit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListKit.h; sourceTree = ""; }; 0B3B929A1E08D7F5008390ED /* IGListAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListAdapter.h; sourceTree = ""; }; @@ -661,6 +667,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 01A3FEE120FFB30100E91657 /* GameplayKit.framework in Frameworks */, 887D0B401D870D7F009E01F7 /* IGListKit.framework in Frameworks */, DD468D380BBF350ACE7EA28B /* Pods_IGListKitTests.framework in Frameworks */, ); @@ -846,6 +853,7 @@ 41882EBBC340173A8053E3AF /* Frameworks */ = { isa = PBXGroup; children = ( + 01A3FEE020FFB30000E91657 /* GameplayKit.framework */, E980179F5E885E417EB20D55 /* Pods_IGListKit_tvOSTests.framework */, 1AB7195278D0BBB5DA88D36F /* Pods_IGListKitTests.framework */, ); @@ -975,6 +983,7 @@ 294AC6311DDE4C19002FCE5D /* IGListDiffResultTests.m */, 88144EE61D870EDC007C7F66 /* IGListDiffSwiftTests.swift */, 88144EE81D870EDC007C7F66 /* IGListDiffTests.m */, + 0179E634207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m */, 88144EE91D870EDC007C7F66 /* IGListDisplayHandlerTests.m */, 29DA5CA21EA7C72400113926 /* IGListGenericSectionControllerTests.m */, 88144EEB1D870EDC007C7F66 /* IGListKitTests-Bridging-Header.h */, @@ -1559,6 +1568,7 @@ files = ( 298DDA381E3B168E00F76F50 /* IGLayoutTestItem.m in Sources */, 885FE2361DC51B76009CE2B4 /* IGListStackSectionControllerTests.m in Sources */, + 0179E636207FA8F30082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m in Sources */, 885FE2311DC51B76009CE2B4 /* IGListDisplayHandlerTests.m in Sources */, 298DDA3B1E3B16F800F76F50 /* IGLayoutTestDataSource.m in Sources */, 29C474901DDF460500AE68CE /* IGListSectionMapTests.m in Sources */, @@ -1681,6 +1691,7 @@ 294AC6321DDE4C19002FCE5D /* IGListDiffResultTests.m in Sources */, 88144F141D870EDC007C7F66 /* IGListTestOffsettingLayout.m in Sources */, 8240C7FB1DC2F6CF00B3AAE7 /* IGListTestAdapterStoryboardDataSource.m in Sources */, + 0179E635207FA8EB0082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m in Sources */, 298DDA011E3AE28000F76F50 /* IGTestDiffingObject.m in Sources */, 88144F131D870EDC007C7F66 /* IGListTestAdapterDataSource.m in Sources */, 88144F071D870EDC007C7F66 /* IGListAdapterE2ETests.m in Sources */, @@ -1732,6 +1743,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0179E637207FA8F30082DFE6 /* IGListDiffExperimentOptimizedMovesTests.m in Sources */, 88DF898A1E010F7000B1B9B4 /* IGListDiffTests.m in Sources */, 88DF89891E010F6500B1B9B4 /* IGListDiffSwiftTests.swift in Sources */, 882BC1321E0118CB0083B311 /* IGTestObject.m in Sources */, diff --git a/Source/Common/IGListDiff.mm b/Source/Common/IGListDiff.mm index 8cd723e73..82019d979 100644 --- a/Source/Common/IGListDiff.mm +++ b/Source/Common/IGListDiff.mm @@ -94,6 +94,105 @@ static void addIndexToCollection(BOOL useIndexPaths, __unsafe_unretained id coll return paths; } +// Calculates indexes which require no explicit moves based on a longest increasing indexes set using O(n log n) complexity algorithm +static NSIndexSet *autoMovedIndexes(const vector &newResultsArray, NSIndexSet *untouchedIndexes) +{ + NSUInteger count = newResultsArray.size(); + vector prevIndexes(count); + vector indexes(count + 1); + + NSUInteger length = 0; + for (NSUInteger i = 0; i < count; i++) { + // Binary search for the largest positive j ≤ length + // such that X[M[j]] < X[i] + NSUInteger lo = 1; + NSUInteger hi = length; + NSInteger currentIndex = newResultsArray[i].index; + + NSUInteger nextUntouched = [untouchedIndexes indexGreaterThanIndex:i]; + + if (nextUntouched != NSNotFound && currentIndex > nextUntouched) { + continue; + } + + NSUInteger prevUntouched = [untouchedIndexes indexLessThanIndex:i]; + + if (prevUntouched != NSNotFound && currentIndex < prevUntouched) { + continue; + } + + while (lo <= hi) { + auto mid = lo + (hi - lo) / 2; + NSInteger prevIndex = newResultsArray[indexes[mid]].index; + if (prevIndex < currentIndex && (prevUntouched == NSNotFound || prevIndex > prevUntouched)) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // After searching, lo is 1 greater than the + // length of the longest prefix of X[i] + auto newLength = lo; + + // The predecessor of X[i] is the last index of + // the subsequence of length newLength-1 + prevIndexes[i] = indexes[newLength - 1]; + indexes[newLength] = i; + + if (newLength > length) { + // If we found a subsequence longer than any we've + // found yet, update L + length = newLength; + } + } + + // Reconstruct the longest increasing indexes + NSMutableIndexSet *result = [NSMutableIndexSet new]; + auto k = indexes[length]; + for (NSUInteger i = 0; i < length; i++) { + NSUInteger index = newResultsArray[k].index; + + // Ignore inserted entries + if (index != NSNotFound) { + [result addIndex:index]; + } + k = prevIndexes[k]; + } + return result; +} + +class IGListMoveChecker { +public: + virtual bool isMove(const NSInteger oldIndex, + const NSInteger newIndex, + const NSInteger insertOffset, + const NSInteger deleteOffset) { + + return (oldIndex - deleteOffset + insertOffset) != newIndex; + } + + virtual ~IGListMoveChecker() {} +}; + +class IGListOptimalMoveChecker : public IGListMoveChecker +{ + NSIndexSet *_autoMovedIndexes; + +public: + IGListOptimalMoveChecker(const vector &newResultsArray, NSIndexSet *untouchedIndexes) + : _autoMovedIndexes(autoMovedIndexes(newResultsArray, untouchedIndexes)) + {} + + virtual bool isMove(const NSInteger oldIndex, + const NSInteger newIndex, + const NSInteger insertOffset, + const NSInteger deleteOffset) { + + return (oldIndex != newIndex) && ![_autoMovedIndexes containsIndex:oldIndex]; + } +}; + static id IGListDiffing(BOOL returnIndexPaths, NSInteger fromSection, NSInteger toSection, @@ -245,9 +344,11 @@ static id IGListDiffing(BOOL returnIndexPaths, } // track offsets from deleted items to calculate where items have moved - vector deleteOffsets(oldCount), insertOffsets(newCount); + vector deleteOffsets(oldCount); NSInteger runningOffset = 0; + auto untouchedIndexes = [NSMutableIndexSet new]; + // iterate old array records checking for deletes // incremement offset for each delete for (NSInteger i = 0; i < oldCount; i++) { @@ -257,22 +358,30 @@ static id IGListDiffing(BOOL returnIndexPaths, if (record.index == NSNotFound) { addIndexToCollection(returnIndexPaths, mDeletes, fromSection, i); runningOffset++; + } else if (record.index == i) { + [untouchedIndexes addIndex:record.index]; } addIndexToMap(returnIndexPaths, fromSection, i, oldArray[i], oldMap); } - // reset and track offsets from inserted items to calculate where items have moved - runningOffset = 0; + + aligned_union<0, IGListMoveChecker, IGListOptimalMoveChecker>::type moveCheckerBuf; + + IGListMoveChecker *moveChecker = IGListExperimentEnabled(experiments, IGListExperimentOptimizedMoves) + ? new (&moveCheckerBuf) IGListOptimalMoveChecker(newResultsArray, untouchedIndexes) + : new (&moveCheckerBuf) IGListMoveChecker(); + + // offset incremented for each insert + NSInteger insertOffset = 0; for (NSInteger i = 0; i < newCount; i++) { - insertOffsets[i] = runningOffset; const IGListRecord record = newResultsArray[i]; const NSInteger oldIndex = record.index; // add to inserts if the opposing index is NSNotFound if (record.index == NSNotFound) { addIndexToCollection(returnIndexPaths, mInserts, toSection, i); - runningOffset++; + insertOffset++; } else { // note that an entry can be updated /and/ moved if (record.entry->updated) { @@ -280,10 +389,11 @@ static id IGListDiffing(BOOL returnIndexPaths, } // calculate the offset and determine if there was a move - // if the indexes match, ignore the index - const NSInteger insertOffset = insertOffsets[i]; const NSInteger deleteOffset = deleteOffsets[oldIndex]; - if ((oldIndex - deleteOffset + insertOffset) != i) { + + if (moveChecker->isMove(oldIndex, i, insertOffset, deleteOffset)) { + + // add move from old index to new index id move; if (returnIndexPaths) { NSIndexPath *from = [NSIndexPath indexPathForItem:oldIndex inSection:fromSection]; @@ -299,6 +409,8 @@ static id IGListDiffing(BOOL returnIndexPaths, addIndexToMap(returnIndexPaths, toSection, i, newArray[i], newMap); } + moveChecker->~IGListMoveChecker(); + NSCAssert((oldCount + [mInserts count] - [mDeletes count]) == newCount, @"Sanity check failed applying %li inserts and %lu deletes to old count %lu equaling new count %li", (long)oldCount, (unsigned long)[mInserts count], (unsigned long)[mDeletes count], (long)newCount); diff --git a/Source/Common/IGListExperiments.h b/Source/Common/IGListExperiments.h index cc61ccaf1..aae293ce2 100644 --- a/Source/Common/IGListExperiments.h +++ b/Source/Common/IGListExperiments.h @@ -26,6 +26,8 @@ typedef NS_OPTIONS (NSInteger, IGListExperiment) { IGListExperimentFasterVisibleSectionController = 1 << 4, /// Test deduping item-level updates. IGListExperimentDedupeItemUpdates = 1 << 5, + /// Test optimized moves to minimal required number + IGListExperimentOptimizedMoves = 1 << 6, }; /** diff --git a/Tests/IGListDiffExperimentOptimizedMovesTests.m b/Tests/IGListDiffExperimentOptimizedMovesTests.m new file mode 100644 index 000000000..612013146 --- /dev/null +++ b/Tests/IGListDiffExperimentOptimizedMovesTests.m @@ -0,0 +1,235 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import +#import +#import +#import +#import +#import "IGListIndexSetResultInternal.h" +#import "IGListMoveIndexInternal.h" +#import "IGListMoveIndexPathInternal.h" + +@interface IGListDiffExperimentOptimizedMovesTests : XCTestCase + +@end + +static NSArray *updatedArray(NSArray *oldArray, NSArray *newArray, IGListIndexSetResult *diff) { + NSMutableArray *result = [oldArray mutableCopy]; + + NSMutableIndexSet *deletes = [diff.deletes mutableCopy]; + NSMutableIndexSet *inserts = [diff.inserts mutableCopy]; + + NSMutableIndexSet *fromIndexes = [NSMutableIndexSet new]; + NSMutableIndexSet *toIndexes = [NSMutableIndexSet new]; + + for (IGListMoveIndex *move in diff.moves) { + [fromIndexes addIndex:move.from]; + [toIndexes addIndex:move.to]; + } + + [deletes addIndexes:fromIndexes]; + [inserts addIndexes:toIndexes]; + + [result removeObjectsAtIndexes:deletes]; + + NSArray *insertedObjects = [newArray objectsAtIndexes:inserts]; + [result insertObjects:insertedObjects atIndexes:inserts]; + + for (IGListMoveIndex *move in diff.moves) { + [result replaceObjectAtIndex:move.to withObject:oldArray[move.from]]; + } + + return result; +} + +@implementation IGListDiffExperimentOptimizedMovesTests + +- (void)test_whenMovingBackward_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @5, @4, @0, @1, @2, @3 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:5 to:0], + [[IGListMoveIndex alloc] initWithFrom:4 to:1] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSMutableIndexSet *expectedDeletes = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSMutableIndexSet *expectedInserts = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenMovingBackwardWithInsertionBefore_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @100, @5, @0, @1, @2, @3, @4 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:5 to:1] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSMutableIndexSet *expectedDeletes = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSIndexSet *expectedInserts = [NSIndexSet indexSetWithIndex:0]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenMovingBackwardWithInsertionInBetween_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @5, @100, @0, @1, @2, @3, @4 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:5 to:0] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSMutableIndexSet *expectedDeletes = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSIndexSet *expectedInserts = [NSIndexSet indexSetWithIndex:1]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenMovingBackwardWithDeletionBefore_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @1, @4, @5, @2, @3 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:4 to:1], + [[IGListMoveIndex alloc] initWithFrom:5 to:2] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSIndexSet *expectedDeletes = [NSIndexSet indexSetWithIndex:0]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSIndexSet *expectedInserts = [NSIndexSet new]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenMovingBackwardWithDeletionInBetween_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @5, @0, @2, @3, @4 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:5 to:0] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSIndexSet *expectedDeletes = [NSIndexSet indexSetWithIndex:1]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSIndexSet *expectedInserts = [NSIndexSet new]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenMovingForward_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @2, @3, @4, @5, @1, @0 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:1 to:4], + [[IGListMoveIndex alloc] initWithFrom:0 to:5] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSMutableIndexSet *expectedDeletes = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSMutableIndexSet *expectedInserts = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenMovingBothWays_thatIndexesMatch { + NSArray *o = @[ @0, @1, @2, @3, @4, @5 ]; + NSArray *n = @[ @1, @2, @0, @5, @3, @4 ]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqual(result.updates.count, 0); + NSArray *expectedMoves = @[ [[IGListMoveIndex alloc] initWithFrom:0 to:2], + [[IGListMoveIndex alloc] initWithFrom:5 to:3] ]; + XCTAssertEqualObjects(result.moves, expectedMoves); + NSMutableIndexSet *expectedDeletes = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.deletes, expectedDeletes); + NSMutableIndexSet *expectedInserts = [NSMutableIndexSet new]; + XCTAssertEqualObjects(result.inserts, expectedInserts); +} + +- (void)test_whenSwappingNeighbors_thatResultHasSingleMove { + NSArray *o = @[@1, @2]; + NSArray *n = @[@2, @1]; + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + NSArray *expected = @[ + [[IGListMoveIndex alloc] initWithFrom:1 to:0] + ]; + XCTAssertEqualObjects(result.moves, expected); + XCTAssertEqual([result changeCount], 1); +} + +- (void)test_whenRandomlyUpdating_thatApplyingDiffProducesSameArray { + NSArray *o = @[ @0, @1, @2, @3, @4, @5, @6, @7, @8, @9 ]; + + for (int testIdx = 0; testIdx < 1000; testIdx ++) { + + NSMutableArray *n = [o mutableCopy]; + + for (int i = 0; i < 2; i++) { + [n removeObjectAtIndex:arc4random_uniform((uint32_t) n.count)]; + } + + NSMutableIndexSet *fromIndexes = [NSMutableIndexSet new]; + NSMutableIndexSet *toIndexes = [NSMutableIndexSet new]; + + for (int i = 0; i < 2; i++) { + NSUInteger fromIdx; + do { + fromIdx = arc4random_uniform((uint32_t) n.count); + } while ([fromIndexes containsIndex:fromIdx] || [toIndexes containsIndex:fromIdx]); + [fromIndexes addIndex:fromIdx]; + + NSUInteger toIdx; + do { + toIdx = arc4random_uniform((uint32_t) n.count); + } while ([toIndexes containsIndex:fromIdx] || [toIndexes containsIndex:toIdx]); + [toIndexes addIndex:toIdx]; + } + + NSArray *movedObjects = [n objectsAtIndexes:fromIndexes]; + [n removeObjectsAtIndexes:fromIndexes]; + [n insertObjects:movedObjects atIndexes:toIndexes]; + + NSMutableIndexSet *inserts = [NSMutableIndexSet new]; + NSMutableArray *insertedObjects = [NSMutableArray new]; + + for (int i = 0; i < 3; i++) { + NSUInteger idx; + do { + idx = arc4random_uniform((uint32_t) n.count); + } while ([inserts containsIndex:idx]); + [inserts addIndex:idx]; + [insertedObjects addObject:@(n.count + i + 10)]; + } + + [n insertObjects:insertedObjects atIndexes:inserts]; + + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqualObjects(updatedArray(o, n, result), n); + } +} + +- (void)test_whenReversing_thatApplyingDiffProducesSameArray { + + NSArray *o = @[ @0, @1, @2, @3, @4, @5, @6, @7, @8, @9 ]; + + NSArray *n = o.reverseObjectEnumerator.allObjects; + + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqualObjects(updatedArray(o, n, result), n); +} + + +- (void)test_whenShuffling_thatApplyingDiffProducesSameArray { + + for (int testIdx = 0; testIdx < 1000; testIdx ++) { + + NSArray *o = @[ @0, @1, @2, @3, @4, @5, @6, @7, @8, @9 ]; + + NSArray *n = [o shuffledArray]; + + IGListIndexSetResult *result = IGListDiffExperiment(o, n, IGListDiffEquality, IGListExperimentOptimizedMoves); + XCTAssertEqualObjects(updatedArray(o, n, result), n); + } +} + +@end