ComponentKitTests/TransactionalDataSource/CKDataSourceTests.mm (438 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 <XCTest/XCTest.h>
#import <ComponentKitTestHelpers/CKAnalyticsListenerSpy.h>
#import <ComponentKitTestHelpers/CKLifecycleTestComponent.h>
#import <ComponentKitTestHelpers/CKTestRunLoopRunning.h>
#import <ComponentKitTestHelpers/CKRenderComponentTestHelpers.h>
#import <ComponentKit/CKComponent.h>
#import <ComponentKit/CKComponentInternal.h>
#import <ComponentKit/CKCompositeComponent.h>
#import <ComponentKit/CKComponentProvider.h>
#import <ComponentKit/CKComponentSubclass.h>
#import <ComponentKit/CKDataSourceAppliedChanges.h>
#import <ComponentKit/CKDataSourceChange.h>
#import <ComponentKit/CKDataSourceChangeset.h>
#import <ComponentKit/CKDataSourceConfiguration.h>
#import <ComponentKit/CKDataSourceConfigurationInternal.h>
#import <ComponentKit/CKDataSourceInternal.h>
#import <ComponentKit/CKDataSourceItem.h>
#import <ComponentKit/CKDataSourceListener.h>
#import <ComponentKit/CKDataSourceState.h>
#import <ComponentKit/CKDataSourceChangesetModification.h>
#import "CKDataSourceStateTestHelpers.h"
static NSString *const kTestInvalidateControllerContext = @"kTestInvalidateControllerContext";
static NSString *const kTestInitialiseControllerContext = @"kTestInitialiseControllerContext";
static NSNumber *const kTestinitialiseControllerModel = @2;
@interface CKDataSourceTests : XCTestCase <CKDataSourceAsyncListener>
@end
@implementation CKDataSourceTests
{
NSMutableArray<CKDataSourceAppliedChanges *> *_announcedChanges;
NSInteger _willGenerateChangeCounter;
NSInteger _didGenerateChangeCounter;
NSInteger _syncModificationStartCounter;
CKDataSourceState *_state;
void(^_didModifyPreviousStateBlock)(void);
UITraitCollection *_currentTraitCollection;
}
static CKComponent *ComponentProvider(id<NSObject> model, id<NSObject> context)
{
if ([context isEqual:kTestInvalidateControllerContext]) {
return CK::ComponentBuilder()
.build();
} else if ([context isEqual:kTestInitialiseControllerContext] || model == kTestinitialiseControllerModel) {
return [CKCompositeComponentWithScope newWithComponentProvider:^{
return [CKLifecycleTestComponent new];
}];
} else {
return [CKLifecycleTestComponent new];
}
}
- (void)setUp
{
[super setUp];
_announcedChanges = [NSMutableArray new];
}
- (void)tearDown
{
[_announcedChanges removeAllObjects];
_willGenerateChangeCounter = 0;
_didGenerateChangeCounter = 0;
_syncModificationStartCounter = 0;
[super tearDown];
}
- (void)testDataSourceSynchronouslyInsertingItemsAnnouncesInsertion
{
CKDataSource *ds = [[CKDataSource alloc]
initWithConfiguration:
[[CKDataSourceConfiguration alloc]
initWithComponentProviderFunc:ComponentProvider
context:nil
sizeRange:{}]];
[ds addListener:self];
CKDataSourceChangeset *insertion =
[[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withInsertedSections:[NSIndexSet indexSetWithIndex:0]]
withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
[ds applyChangeset:insertion mode:CKUpdateModeSynchronous userInfo:nil];
CKDataSourceAppliedChanges *expectedAppliedChanges =
[[CKDataSourceAppliedChanges alloc] initWithUpdatedIndexPaths:nil
removedIndexPaths:nil
removedSections:nil
movedIndexPaths:nil
insertedSections:[NSIndexSet indexSetWithIndex:0]
insertedIndexPaths:[NSSet setWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]
userInfo:nil];
XCTAssertEqualObjects(_announcedChanges.firstObject, expectedAppliedChanges);
XCTAssertEqual(_syncModificationStartCounter, 1);
XCTAssertEqual(_willGenerateChangeCounter, 0);
XCTAssertEqual(_didGenerateChangeCounter, 0);
}
- (void)testDataSourceAsynchronouslyInsertingItemsAnnouncesInsertionAsynchronously
{
CKDataSource *ds = [[CKDataSource alloc]
initWithConfiguration:
[[CKDataSourceConfiguration alloc]
initWithComponentProviderFunc:ComponentProvider
context:nil
sizeRange:{}]];
[ds addListener:self];
CKDataSourceChangeset *insertion =
[[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withInsertedSections:[NSIndexSet indexSetWithIndex:0]]
withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
[ds applyChangeset:insertion mode:CKUpdateModeAsynchronous userInfo:nil];
CKDataSourceAppliedChanges *expectedAppliedChanges =
[[CKDataSourceAppliedChanges alloc] initWithUpdatedIndexPaths:nil
removedIndexPaths:nil
removedSections:nil
movedIndexPaths:nil
insertedSections:[NSIndexSet indexSetWithIndex:0]
insertedIndexPaths:[NSSet setWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]
userInfo:nil];
XCTAssertTrue(CKRunRunLoopUntilBlockIsTrue(^BOOL(void){
return [_announcedChanges.firstObject isEqual:expectedAppliedChanges];
}));
XCTAssertEqual(_syncModificationStartCounter, 0);
XCTAssertEqual(_willGenerateChangeCounter, 1);
XCTAssertEqual(_didGenerateChangeCounter, 1);
}
- (void)testDataSourceUpdatingConfigurationAnnouncesUpdate
{
CKDataSource *ds = CKComponentTestDataSource(ComponentProvider, self);
CKDataSourceConfiguration *config = [[CKDataSourceConfiguration alloc] initWithComponentProviderFunc:ComponentProvider
context:@"new context"
sizeRange:{}];
[ds updateConfiguration:config mode:CKUpdateModeSynchronous userInfo:nil];
CKDataSourceAppliedChanges *expectedAppliedChanges =
[[CKDataSourceAppliedChanges alloc] initWithUpdatedIndexPaths:[NSSet setWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]
removedIndexPaths:nil
removedSections:nil
movedIndexPaths:nil
insertedSections:nil
insertedIndexPaths:nil
userInfo:nil];
XCTAssertEqual(_announcedChanges.count, 2);
XCTAssertEqualObjects(_announcedChanges[1], expectedAppliedChanges);
XCTAssertEqual([_state configuration], config);
XCTAssertEqual(_syncModificationStartCounter, 2);
XCTAssertEqual(_willGenerateChangeCounter, 0);
XCTAssertEqual(_didGenerateChangeCounter, 0);
}
- (void)testDataSourceReloadingAnnouncesUpdate
{
CKDataSource *ds = CKComponentTestDataSource(ComponentProvider, self);
[ds reloadWithMode:CKUpdateModeSynchronous userInfo:nil];
CKDataSourceAppliedChanges *expectedAppliedChanges =
[[CKDataSourceAppliedChanges alloc] initWithUpdatedIndexPaths:[NSSet setWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]
removedIndexPaths:nil
removedSections:nil
movedIndexPaths:nil
insertedSections:nil
insertedIndexPaths:nil
userInfo:nil];
XCTAssertEqual(_announcedChanges.count, 2);
XCTAssertEqualObjects(_announcedChanges[1], expectedAppliedChanges);
XCTAssertEqual(_syncModificationStartCounter, 2);
XCTAssertEqual(_willGenerateChangeCounter, 0);
XCTAssertEqual(_didGenerateChangeCounter, 0);
}
- (void)testDataSourceSynchronousReloadCancelsPreviousAsynchronousReload
{
CKDataSource *ds = CKComponentTestDataSource(ComponentProvider, self);
// The initial asynchronous reload should be canceled by the immediately subsequent synchronous reload.
// We then request *another* async reload so that we can wait for it to complete and assert that the initial
// async reload doesn't actually take effect after the synchronous reload.
[ds reloadWithMode:CKUpdateModeAsynchronous userInfo:@{@"id": @1}];
[ds reloadWithMode:CKUpdateModeSynchronous userInfo:@{@"id": @2}];
[ds reloadWithMode:CKUpdateModeAsynchronous userInfo:@{@"id": @3}];
CKDataSourceAppliedChanges *expectedAppliedChangesForSyncReload =
[[CKDataSourceAppliedChanges alloc] initWithUpdatedIndexPaths:[NSSet setWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]
removedIndexPaths:nil
removedSections:nil
movedIndexPaths:nil
insertedSections:nil
insertedIndexPaths:nil
userInfo:@{@"id": @2}];
CKDataSourceAppliedChanges *expectedAppliedChangesForSecondAsyncReload =
[[CKDataSourceAppliedChanges alloc] initWithUpdatedIndexPaths:[NSSet setWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]
removedIndexPaths:nil
removedSections:nil
movedIndexPaths:nil
insertedSections:nil
insertedIndexPaths:nil
userInfo:@{@"id": @3}];
XCTAssertTrue(CKRunRunLoopUntilBlockIsTrue(^BOOL{
return _announcedChanges.count == 3
&& [_announcedChanges[1] isEqual:expectedAppliedChangesForSyncReload]
&& [_announcedChanges[2] isEqual:expectedAppliedChangesForSecondAsyncReload];
}));
XCTAssertEqual(_syncModificationStartCounter, 2);
}
- (void)testDataSourceDeallocatingDataSourceTriggersInvalidateOnMainThread
{
CKLifecycleTestComponentController *controller = nil;
@autoreleasepool {
// We dispatch empty operation on Data Source to background so that
// DataSource deallocation is also triggered on background.
// CKLifecycleTestComponent will assert if it receives an invalidation not on the main thread,
CKDataSource *dataSource = CKComponentTestDataSource(ComponentProvider, self);
controller = ((CKLifecycleTestComponent *)[[_state objectAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]] rootLayout].component()).controller;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[dataSource hash];
});
}
XCTAssertTrue(CKRunRunLoopUntilBlockIsTrue(^BOOL(void){
return controller.calledInvalidateController;
}));
}
- (void)testDataSourceAddingComponentByUpdatingConfigurationTriggersDidInitOnMainThread
{
const auto firstIndexPath = [NSIndexPath indexPathForRow:0 inSection:0];
CKDataSource *dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto firstController = (CKLifecycleTestComponentController *)((CKLifecycleTestComponent *)[[_state objectAtIndexPath:firstIndexPath] rootLayout].component()).controller;
XCTAssertTrue(firstController.calledDidInit);
[dataSource updateConfiguration:[_state.configuration copyWithContext:kTestInitialiseControllerContext sizeRange:{}]
mode:CKUpdateModeSynchronous
userInfo:@{}];
const auto secondController = (CKLifecycleTestComponentController *)((CKComponent *)((CKCompositeComponent *)[[_state objectAtIndexPath:firstIndexPath] rootLayout].component()).child).controller;
XCTAssertNotEqual(firstController, secondController);
XCTAssertTrue(firstController.calledInvalidateController);
XCTAssertTrue(secondController.calledDidInit);
}
- (void)testDataSourceAddingComponentByApplyingChangesetTriggersDidInitOnMainThread
{
const auto firstIndexPath = [NSIndexPath indexPathForRow:0 inSection:0];
CKDataSource *dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto firstController = (CKLifecycleTestComponentController *)((CKLifecycleTestComponent *)[[_state objectAtIndexPath:firstIndexPath] rootLayout].component()).controller;
XCTAssertTrue(firstController.calledDidInit);
[dataSource applyChangeset:[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withUpdatedItems:@{firstIndexPath: kTestinitialiseControllerModel}]
build]
mode:CKUpdateModeSynchronous
userInfo:@{}];
const auto secondController = (CKLifecycleTestComponentController *)((CKComponent *)((CKCompositeComponent *)[[_state objectAtIndexPath:firstIndexPath] rootLayout].component()).child).controller;
XCTAssertNotEqual(firstController, secondController);
XCTAssertTrue(firstController.calledInvalidateController);
XCTAssertTrue(secondController.calledDidInit);
}
- (void)testDataSourceRemovingComponentTriggersInvalidateOnMainThread
{
CKDataSource *dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto controller = ((CKLifecycleTestComponent *)[[_state objectAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]] rootLayout].component()).controller;
[dataSource updateConfiguration:[_state.configuration copyWithContext:kTestInvalidateControllerContext sizeRange:{}]
mode:CKUpdateModeSynchronous
userInfo:@{}];
XCTAssertTrue(controller.calledInvalidateController);
}
- (void)testDataSourceRemovingItemTriggersInvalidateOnMainThread
{
CKDataSource *dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto controller = ((CKLifecycleTestComponent *)[[_state objectAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]] rootLayout].component()).controller;
[dataSource applyChangeset:[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withRemovedItems:[NSSet setWithObject:[NSIndexPath indexPathForRow:0 inSection:0]]]
build]
mode:CKUpdateModeSynchronous
userInfo:@{}];
XCTAssertTrue(controller.calledInvalidateController);
}
- (void)testDataSourceRemovingSectionTriggersInvalidateOnMainThread
{
CKDataSource *dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto controller = ((CKLifecycleTestComponent *)[[_state objectAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]] rootLayout].component()).controller;
[dataSource applyChangeset:[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withRemovedSections:[NSIndexSet indexSetWithIndex:0]]
build]
mode:CKUpdateModeSynchronous
userInfo:@{}];
XCTAssertTrue(controller.calledInvalidateController);
}
- (void)testDataSourceApplyingPrecomputedChange
{
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto insertion =
[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
const auto modification =
[[CKDataSourceChangesetModification alloc]
initWithChangeset:insertion
stateListener:nil userInfo:@{} qos:CKDataSourceQOSDefault];
const auto change = [modification changeFromState:_state];
const auto isApplied = [dataSource applyChange:change];
XCTAssertTrue(isApplied, @"Change should be applied to datasource successfully.");
XCTAssertEqual(_state, change.state);
}
- (void)testDataSourceApplyingPrecomputedChangeAfterStateIsChanged
{
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto insertion =
[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
const auto modification =
[[CKDataSourceChangesetModification alloc]
initWithChangeset:insertion
stateListener:nil userInfo:@{} qos:CKDataSourceQOSDefault];
const auto change = [modification changeFromState:_state];
[dataSource reloadWithMode:CKUpdateModeSynchronous userInfo:@{}];
const auto newState = _state;
const auto isApplied = [dataSource applyChange:change];
XCTAssertFalse(isApplied, @"Applying change to datasource should fail.");
XCTAssertEqualObjects(_state, newState, @"State should remain the same.");
}
- (void)testDataSourceVerifyingPrecomputedChange
{
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto insertion =
[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
const auto modification =
[[CKDataSourceChangesetModification alloc]
initWithChangeset:insertion
stateListener:nil userInfo:@{} qos:CKDataSourceQOSDefault];
const auto change = [modification changeFromState:_state];
const auto isValid = [dataSource verifyChange:change];
XCTAssertTrue(isValid, @"Change should be valid.");
}
- (void)testDataSourceVerifyingPrecomputedChangeAfterStateIsChanged
{
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self);
const auto insertion =
[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withInsertedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
const auto modification =
[[CKDataSourceChangesetModification alloc]
initWithChangeset:insertion
stateListener:nil userInfo:@{} qos:CKDataSourceQOSDefault];
const auto change = [modification changeFromState:_state];
[dataSource reloadWithMode:CKUpdateModeSynchronous userInfo:@{}];
const auto isValid = [dataSource verifyChange:change];
XCTAssertFalse(isValid, @"Change should not be valid since state has changed.");
}
- (void)testDataSourceComponentInControllerIsNotUpdatedAfterComponentBuild
{
CKComponentController *componentController = nil;
// Autorelease pool is needed here to make sure `oldState` is deallocated so that weak reference of component
// in `CKComponentController` is nil.
@autoreleasepool {
const auto dataSource = CKComponentTestDataSource(ComponentProvider,
self,
{});
CKComponent *component = (CKComponent *)[_state objectAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]].rootLayout.component();
componentController = component.controller;
const auto update =
[[[CKDataSourceChangesetBuilder dataSourceChangeset]
withUpdatedItems:@{[NSIndexPath indexPathForItem:0 inSection:0]: @1}]
build];
[dataSource applyChangeset:update mode:CKUpdateModeSynchronous userInfo:@{}];
}
// `latestComponent` is updated so `componentController.component` returns the latest generation of component even
// after `oldState` is deallocated.
XCTAssertNotEqual(componentController.component, nil);
}
/**
This test covers the case when a "re-entrant" changeset application happens in `didModifyPreviousState`
event callback. We should make sure no redundant work is done in background queue becasue of this.
*/
- (void)testDataSourceApplyingChangesetInDidModifyPreviousStateCallback
{
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self);
_didModifyPreviousStateBlock = ^{
[dataSource
applyChangeset:[[CKDataSourceChangesetBuilder dataSourceChangeset] build]
mode:CKUpdateModeAsynchronous
userInfo:@{}];
_didModifyPreviousStateBlock = nil;
};
// Applying this changeset asynchronously triggers another changeset application in `_didModifyPreviousStateBlock`.
[dataSource
applyChangeset:[[CKDataSourceChangesetBuilder dataSourceChangeset] build]
mode:CKUpdateModeAsynchronous
userInfo:@{}];
CKRunRunLoopUntilBlockIsTrue(^BOOL{
return _announcedChanges.count == 3;
});
// Applying another changeset to make sure all asynchronous work is finished in background queue.
[dataSource
applyChangeset:[[CKDataSourceChangesetBuilder dataSourceChangeset] build]
mode:CKUpdateModeAsynchronous
userInfo:@{}];
CKRunRunLoopUntilBlockIsTrue(^BOOL{
return _announcedChanges.count == 4;
});
// `_willGenerateChangeCounter` matches the number of asynchronous changeset applications.
XCTAssertEqual(_willGenerateChangeCounter, 3);
}
- (void)test_WhenReceivesStateUpdate_ReportsToAnalyticsListener
{
const auto analyticsListenerSpy = [CKAnalyticsListenerSpy new];
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self, analyticsListenerSpy);
const auto item = [_state objectAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
const auto handle =
[[CKComponentScopeHandle alloc] initWithListener:dataSource
rootIdentifier:[item.scopeRoot globalIdentifier]
componentTypeName:"CKTestComponent"
initialState:nil];
[dataSource componentScopeHandle:handle
rootIdentifier:[item.scopeRoot globalIdentifier]
didReceiveStateUpdate:^(id x){ return x; }
metadata:{}
mode:CKUpdateModeSynchronous];
const auto event = analyticsListenerSpy.events.front();
event.match([&](CK::AnalyticsListenerSpy::DidReceiveStateUpdate drsu){
XCTAssertEqual(drsu.handle, handle);
XCTAssertEqual(drsu.rootID, [item.scopeRoot globalIdentifier]);
});
}
- (void)test_WhenTraitCollectionIsSet_CurrentTraitCollectionIsCorrectInWorkQueue
{
if (@available(iOS 13.0, tvOS 13.0, *)) {
const auto dataSource = CKComponentTestDataSource(ComponentProvider, self);
[dataSource setTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceIdiom:UIUserInterfaceIdiomCarPlay]];
[dataSource
applyChangeset:[[CKDataSourceChangesetBuilder dataSourceChangeset] build]
mode:CKUpdateModeAsynchronous
userInfo:@{}];
CKRunRunLoopUntilBlockIsTrue(^BOOL{
return _didGenerateChangeCounter == 1;
});
XCTAssertEqual(_currentTraitCollection.userInterfaceIdiom, UIUserInterfaceIdiomCarPlay);
}
}
#pragma mark - Listener
- (void)dataSource:(CKDataSource *)dataSource
didModifyPreviousState:(CKDataSourceState *)previousState
withState:(CKDataSourceState *)state
byApplyingChanges:(CKDataSourceAppliedChanges *)changes
{
_state = state;
[_announcedChanges addObject:changes];
if (_didModifyPreviousStateBlock) {
_didModifyPreviousStateBlock();
}
}
- (void)dataSource:(CKDataSource *)dataSource willSyncApplyModificationWithUserInfo:(NSDictionary *)userInfo
{
_syncModificationStartCounter++;
}
- (void)dataSource:(CKDataSource *)dataSource willGenerateNewStateWithUserInfo:(NSDictionary *)userInfo
{
_willGenerateChangeCounter++;
if (@available(iOS 13.0, tvOS 13.0, *)) {
_currentTraitCollection = [UITraitCollection currentTraitCollection];
}
}
- (void)dataSource:(CKDataSource *)dataSource didGenerateNewState:(CKDataSourceState *)newState changes:(CKDataSourceAppliedChanges *)changes
{
_didGenerateChangeCounter++;
}
- (void)dataSource:(CKDataSource *)dataSource
willApplyDeferredChangeset:(CKDataSourceChangeset *)deferredChangeset {}
@end