ComponentKitTests/TransactionalDataSource/CKDataSourceChangesetApplicatorTests.mm (418 lines of code) (raw):
/*
 *  Copyright (c) 2014-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 <mutex>
#import <XCTest/XCTest.h>
#import <ComponentKit/CKAnalyticsListener.h>
#import <ComponentKit/CKComponent.h>
#import <ComponentKit/CKDataSourceChange.h>
#import <ComponentKit/CKDataSourceChangeset.h>
#import <ComponentKit/CKDataSourceConfigurationInternal.h>
#import <ComponentKit/CKDataSourceInternal.h>
#import <ComponentKit/CKDataSourceListener.h>
#import <ComponentKit/CKDataSourceStateInternal.h>
#import <ComponentKit/CKDataSourceChangesetApplicator.h>
#import <ComponentKitTestHelpers/CKTestRunLoopRunning.h>
@interface CKDataSourceMock : CKDataSource
// Indicates how many times `verifyChange:` is called.
@property (nonatomic, readonly, assign) NSUInteger verifyChangeCount;
// Indicates how many times `applyChange:` is called if change is verified.
@property (nonatomic, readonly, assign) NSUInteger applyChangeCount;
@property (nonatomic, copy) void(^willVerifyChange)(void);
@property (nonatomic, copy) void(^didApplyChange)(void);
- (void)sendNewState;
- (void)sendNewStateWithSizeRange:(CKSizeRange)sizeRange;
@end
@interface CKDataSourceChangesetApplicatorTests : XCTestCase <CKAnalyticsListener>
@end
@implementation CKDataSourceChangesetApplicatorTests
{
  CKDataSourceMock *_dataSource;
  CKDataSourceChangesetApplicator *_changesetApplicator;
  dispatch_queue_t _queue;
  std::atomic<NSUInteger> _buildComponentCount;
  UITraitCollection *_currentTraitCollection;
}
- (void)setUp
{
  const auto configuration =
  [[CKDataSourceConfiguration alloc]
   initWithComponentProviderFunc:componentProvider
   context:nil
   sizeRange:{{100, 100}, {100, 100}}
   options:{}
   componentPredicates:{}
   componentControllerPredicates:{}
   analyticsListener:self];
  const auto dataSourceState = [[CKDataSourceState alloc] initWithConfiguration:configuration sections:@[]];
  _dataSource = [[CKDataSourceMock alloc] initWithState:dataSourceState];
  _queue = dispatch_queue_create("CKDataSourceChangesetApplicator.Tests", DISPATCH_QUEUE_SERIAL);
  _changesetApplicator =
  [[CKDataSourceChangesetApplicator alloc]
   initWithDataSource:_dataSource
   queue:_queue];
  _buildComponentCount = 0;
}
- (void)tearDown
{
  _dataSource = nil;
  _changesetApplicator = nil;
}
- (void)testChangeIsAppliedAfterApplyChangesetIsCalled
{
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator applyChangeset:defaultChangeset()
                                      userInfo:@{}
                                           qos:CKDataSourceQOSDefault];
  });
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:1 numberOfFailedChanges:0];
}
- (void)testChangesAreAppliedSequentiallyAfterApplyChangesetIsCalledMultipleTimes
{
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:2 numberOfFailedChanges:0];
}
- (void)testChangesetsAreReappliedIfDataSourceStateIsChangedWhenProcessingChangesets
{
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [_dataSource sendNewState];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:2 numberOfFailedChanges:2];
}
- (void)testSecondChangesetIsReappliedIfDataSourceStateIsChangedWhenProcessingSecondChangeset
{
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [_dataSource sendNewState];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:2 numberOfFailedChanges:1];
}
- (void)testDataSourceItemCacheIsUsedWhenChangesetIsReapplied
{
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
        withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @0}]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]]
      build]
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [_dataSource sendNewState];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:1 numberOfFailedChanges:1];
  XCTAssertTrue(_buildComponentCount == 1, @"`dataSourceItem` should only be built once because of cache.");
}
- (void)testDataSourceItemCacheIsNotUsedIfConfigurationIsChangedWhenChangesetIsReapplied
{
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
        withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @0}]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]]
      build]
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [_dataSource sendNewStateWithSizeRange:{{0, 0}, {200, 200}}];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:1 numberOfFailedChanges:1];
  XCTAssertTrue(_buildComponentCount == 2, @"`dataSourceItem` should be built twice because cache is invalidated.");
}
- (void)testStateUpdatesArePausedInDataSourceAfterApplyChangesetIsCalled
{
  XCTAssertFalse(_dataSource.shouldPauseStateUpdates);
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:defaultChangeset()
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
  });
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  XCTAssertTrue(_dataSource.shouldPauseStateUpdates);
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  // After changeset applicator finishes processing changeset, `shouldPauseStateUpdates` is set to NO.
  XCTAssertFalse(_dataSource.shouldPauseStateUpdates);
}
- (void)testSpiltChangesetIsAppliedWithoutDefferedChangesetWhenViewportIsLargeEnough
{
  [self enableSplitChangeset];
  [_changesetApplicator setViewPort:{.size = {100, 200}}];
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
       withInsertedItems:@{
         [NSIndexPath indexPathForItem:0 inSection:0]: @0,
         [NSIndexPath indexPathForItem:1 inSection:0]: @1,
       }]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]] build]
     userInfo:@{}qos:CKDataSourceQOSDefault];
  });
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:1 numberOfFailedChanges:0];
}
- (void)testSpiltChangesetIsAppliedWithDefferedChangesetWhenViewportIsNotLargeEnough
{
  [self enableSplitChangeset];
  [_changesetApplicator setViewPort:{.size = {100, 100}}];
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
       withInsertedItems:@{
         [NSIndexPath indexPathForItem:0 inSection:0]: @0,
         [NSIndexPath indexPathForItem:1 inSection:0]: @1,
       }]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]] build]
     userInfo:@{}qos:CKDataSourceQOSDefault];
  });
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:2 numberOfFailedChanges:0];
}
- (void)testSpiltChangesetIsReappliedWithoutDefferedChangesetWhenDataSourceStateIsChanged
{
  [self enableSplitChangeset];
  [_changesetApplicator setViewPort:{.size = {100, 200}}];
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
       withInsertedItems:@{
         [NSIndexPath indexPathForItem:0 inSection:0]: @0,
         [NSIndexPath indexPathForItem:1 inSection:0]: @1,
       }]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]] build]
     userInfo:@{}qos:CKDataSourceQOSDefault];
  });
  [_dataSource sendNewState];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:1 numberOfFailedChanges:1];
}
- (void)testSpiltChangesetIsReappliedWithDefferedChangesetWhenDataSourceStateIsChanged
{
  [self enableSplitChangeset];
  [_changesetApplicator setViewPort:{.size = {100, 100}}];
  dispatch_sync(_queue, ^{
    [self->_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
       withInsertedItems:@{
         [NSIndexPath indexPathForItem:0 inSection:0]: @0,
         [NSIndexPath indexPathForItem:1 inSection:0]: @1,
       }]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]] build]
     userInfo:@{}qos:CKDataSourceQOSDefault];
  });
  [_dataSource sendNewState];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self waitUntilChangesetApplicatorQueueIsIdle];
  [self waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue];
  [self assertNumberOfSuccessfulChanges:2 numberOfFailedChanges:2];
}
- (void)testCurrentTraitCollectionIsCorrectInWorkQueue
{
  if (@available(iOS 13.0, tvOS 13.0, *)) {
    [_changesetApplicator setTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceIdiom:UIUserInterfaceIdiomCarPlay]];
    [_changesetApplicator
     applyChangeset:
     [[[[CKDataSourceChangesetBuilder dataSourceChangeset]
        withInsertedItems:@{
          [NSIndexPath indexPathForItem:0 inSection:0]: @0,
        }]
       withInsertedSections:[NSIndexSet indexSetWithIndex:0]] build]
     userInfo:@{}
     qos:CKDataSourceQOSDefault];
    CKRunRunLoopUntilBlockIsTrue(^BOOL{
      return _buildComponentCount == 1;
    });
    XCTAssertEqual(_currentTraitCollection.userInterfaceIdiom, UIUserInterfaceIdiomCarPlay);
  }
}
static CKComponent *componentProvider(id<NSObject> model, id<NSObject> context)
{
  return CK::ComponentBuilder()
    .size({100, 100})
    .build();
}
#pragma mark - CKAnalyticsListener
- (void)willBuildComponentTreeWithScopeRoot:(CKComponentScopeRoot *)scopeRoot
                               buildTrigger:(CKBuildTrigger)buildTrigger
                               stateUpdates:(const CKComponentStateUpdateMap &)stateUpdates
{
  if (@available(iOS 13.0, tvOS 13.0, *)) {
    _currentTraitCollection = [UITraitCollection currentTraitCollection];
  }
  _buildComponentCount++;
}
- (void)didBuildComponentTreeWithScopeRoot:(CKComponentScopeRoot *)scopeRoot
                              buildTrigger:(CKBuildTrigger)buildTrigger
                              stateUpdates:(const CKComponentStateUpdateMap &)stateUpdates
                                 component:(CKComponent *)component
                           boundsAnimation:(const CKComponentBoundsAnimation &)boundsAnimation
{
}
- (void)didCollectAnimations:(const CKComponentAnimations &)animations
              fromComponents:(const CK::ComponentTreeDiff &)animatedComponents
inComponentTreeWithRootComponent:(id<CKMountable>)component
         scopeRootIdentifier:(CKComponentScopeRootIdentifier)scopeRootID
{
}
- (void)didLayoutComponentTreeWithRootComponent:(id<CKMountable>)component
{
}
- (void)didMountComponentTreeWithRootComponent:(id<CKMountable>)component
                         mountAnalyticsContext:(CK::Optional<CK::Component::MountAnalyticsContext>)mountAnalyticsContext
{
}
- (void)didReuseNode:(CKTreeNode *)node
         inScopeRoot:(CKComponentScopeRoot *)scopeRoot
fromPreviousScopeRoot:(CKComponentScopeRoot *)previousScopeRoot
{
}
- (BOOL)shouldCollectMountInformationForRootComponent:(CKComponent *)component
{
  return NO;
}
- (id<CKSystraceListener>)systraceListener
{
  return nil;
}
- (BOOL)shouldCollectTreeNodeCreationInformation:(CKComponentScopeRoot *)scopeRoot { return NO; }
- (void)didBuildTreeNodeForPrecomputedChild:(id<CKComponentProtocol>)component
                                       node:(CKTreeNode *)node
                                     parent:(CKTreeNode *)parent
                                     params:(const CKBuildComponentTreeParams &)params
                       parentHasStateUpdate:(BOOL)parentHasStateUpdate {}
- (void)willCollectAnimationsFromComponentTreeWithRootComponent:(id<CKMountable>)component
{
}
- (void)willLayoutComponentTreeWithRootComponent:(id<CKMountable>)component buildTrigger:(CK::Optional<CKBuildTrigger>)buildTrigger
{
}
- (void)willMountComponentTreeWithRootComponent:(id<CKMountable>)component
{
}
- (void)didReceiveStateUpdateFromScopeHandle:(CKComponentScopeHandle *)handle rootIdentifier:(CKComponentScopeRootIdentifier)rootID {
}
#pragma mark - Helpers
- (void)enableSplitChangeset
{
  const auto preivousConfiguration = _dataSource.state.configuration;
  const auto configuration =
  [[CKDataSourceConfiguration alloc]
   initWithComponentProviderFunc:componentProvider
   context:preivousConfiguration.context
   sizeRange:preivousConfiguration.sizeRange
   options:{
    .splitChangesetOptions = {
      .enabled = YES,
    },
   }
   componentPredicates:preivousConfiguration.componentPredicates
   componentControllerPredicates:preivousConfiguration.componentControllerPredicates
   analyticsListener:preivousConfiguration.analyticsListener];
  [_dataSource updateConfiguration:configuration mode:CKUpdateModeSynchronous userInfo:@{}];
}
- (void)waitUntilChangesetApplicatorFinishesItsTasksOnMainQueue
{
  __block BOOL didRun = NO;
  dispatch_async(dispatch_get_main_queue(), ^{
    didRun = YES;
  });
  CKRunRunLoopUntilBlockIsTrue(^BOOL{
    return didRun;
  });
}
- (void)waitUntilChangesetApplicatorQueueIsIdle
{
  // Use `dispatch_sync` to block the main queue until the queue finishes its current task.
  dispatch_sync(_queue, ^{});
}
- (void)assertNumberOfSuccessfulChanges:(NSUInteger)numberOfSuccessfulChanges
                  numberOfFailedChanges:(NSUInteger)numberOfFailedChanges
{
  // Number is doubled because internally `applyChange` calls `verifyChange` as well.
  XCTAssertEqual(_dataSource.verifyChangeCount, (numberOfFailedChanges + numberOfSuccessfulChanges) * 2);
  XCTAssertEqual(_dataSource.applyChangeCount, numberOfSuccessfulChanges);
}
static CKDataSourceChangeset *defaultChangeset()
{
  return [[CKDataSourceChangesetBuilder dataSourceChangeset] build];
}
@end
#pragma mark - CKDataSourceMock
@implementation CKDataSourceMock
- (BOOL)applyChange:(CKDataSourceChange *)change
{
  const auto applied = [super applyChange:change];
  if (applied) {
    _applyChangeCount++;
    if (_didApplyChange) {
      _didApplyChange();
    }
  }
  return applied;
}
- (BOOL)verifyChange:(CKDataSourceChange *)change
{
  _verifyChangeCount++;
  if (_willVerifyChange) {
    _willVerifyChange();
  }
  return [super verifyChange:change];
}
- (void)sendNewState
{
  [self sendNewStateWithSizeRange:self.state.configuration.sizeRange];
}
- (void)sendNewStateWithSizeRange:(CKSizeRange)sizeRange
{
  const auto configuration =
  [[CKDataSourceConfiguration alloc]
   initWithComponentProviderFunc:componentProvider
   context:self.state.configuration.context
   sizeRange:sizeRange
   options:self.state.configuration.options
   componentPredicates:self.state.configuration.componentPredicates
   componentControllerPredicates:self.state.configuration.componentControllerPredicates
   analyticsListener:self.state.configuration.analyticsListener];
  [self updateConfiguration:configuration mode:CKUpdateModeSynchronous userInfo:@{}];
}
@end