ComponentKitTests/CKComponentHostingViewTests.mm (371 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/CKEmbeddedTestComponent.h>
#import <ComponentKitTestHelpers/CKTestRunLoopRunning.h>
#import <ComponentKitTestHelpers/CKLifecycleTestComponent.h>
#import <ComponentKit/CKBuildComponent.h>
#import <ComponentKit/CKComponent.h>
#import <ComponentKit/CKComponentFlexibleSizeRangeProvider.h>
#import <ComponentKit/CKComponentHostingView.h>
#import <ComponentKit/CKComponentHostingViewDelegate.h>
#import <ComponentKit/CKAnalyticsListener.h>
#import <ComponentKit/CKComponentHostingViewInternal.h>
#import <ComponentKit/CKOptional.h>
#import <ComponentKitTestHelpers/CKAnalyticsListenerSpy.h>
#import "CKComponentHostingViewTestModel.h"
typedef struct {
BOOL allowTapPassthrough;
CKComponentHostingViewWrapperType wrapperType;
id<CKAnalyticsListener> analyticsListener;
id<CKComponentSizeRangeProviding> sizeRangeProvider;
CK::Optional<CGSize> initialSize;
BOOL shouldUpdateModelAfterCreation = YES;
void(^willGenerateComponent)();
} CKComponentHostingViewConfiguration;
static CKComponent *CKComponentTestComponentProviderFunc(id<NSObject> model, id<NSObject> context)
{
return CKComponentWithHostingViewTestModel(model);
}
@interface CKComponentHostingViewTests : XCTestCase <CKComponentHostingViewDelegate>
+ (CKComponentHostingView *)makeHostingView:(const CKComponentHostingViewConfiguration &)options;
@end
@implementation CKComponentHostingViewTests {
BOOL _calledSizeDidInvalidate;
CKAnalyticsListenerSpy *_analyticsListenerSpy;
}
+ (CKComponentHostingView *)hostingView:(const CKComponentHostingViewConfiguration &)options
{
auto const model = [[CKComponentHostingViewTestModel alloc]
initWithColor:[UIColor orangeColor]
size:RCComponentSize::fromCGSize(CGSizeMake(50, 50))
wrapperType:options.wrapperType
willGenerateComponent:options.willGenerateComponent];
auto const view = [self makeHostingView:options];
if (options.shouldUpdateModelAfterCreation) {
view.bounds = CGRectMake(0, 0, 100, 100);
[view updateModel:model mode:CKUpdateModeSynchronous];
[view layoutIfNeeded];
}
return view;
}
+ (CKComponentHostingView *)makeHostingView:(const CKComponentHostingViewConfiguration &)options
{
return [[CKComponentHostingView alloc] initWithComponentProviderFunc:CKComponentTestComponentProviderFunc
sizeRangeProvider:options.sizeRangeProvider ?: [CKComponentFlexibleSizeRangeProvider providerWithFlexibility:CKComponentSizeRangeFlexibleWidthAndHeight]
componentPredicates:{}
componentControllerPredicates:{}
analyticsListener:options.analyticsListener
options:{
.allowTapPassthrough = options.allowTapPassthrough,
.initialSize = options.initialSize,
}];
}
/// Used to identify component provider class / function
+ (NSString *)componentProviderIdentifier
{
return [NSString stringWithFormat:@"%p", [CKComponentHostingViewTests class]];
}
+ (CK::NonNull<NSString *>)rootViewCategory
{
return CK::makeNonNull([NSString stringWithFormat:@"%@-%@",
NSStringFromClass([CKComponentHostingView class]),
[self componentProviderIdentifier]]);
}
- (void)setUp
{
[super setUp];
_calledSizeDidInvalidate = NO;
_analyticsListenerSpy = [CKAnalyticsListenerSpy new];
}
- (void)testInitializationInsertsContainerViewInHierarchy
{
CKComponentHostingView *view = [[self class] hostingView:{}];
XCTAssertTrue(view.subviews.count == 1, @"Expect hosting view to have a single subview.");
}
- (void)testInitializationInsertsComponentViewInHierarchy
{
CKComponentHostingView *view = [[self class] hostingView:{}];
XCTAssertTrue([view.containerView.subviews count] > 0, @"Expect that initialization should insert component view as subview of container view.");
}
- (void)testUpdatingHostingViewBoundsResizesComponentView
{
CKComponentHostingView *view = [[self class] hostingView:{}];
view.bounds = CGRectMake(0, 0, 200, 200);
[view layoutIfNeeded];
UIView *componentView = [view.containerView.subviews firstObject];
XCTAssertEqualObjects(componentView.backgroundColor, [UIColor orangeColor], @"Expected to find orange component view");
XCTAssertTrue(CGRectEqualToRect(componentView.bounds, CGRectMake(0, 0, 200, 200)));
}
- (void)testImmediatelyUpdatesViewOnSynchronousModelChange
{
CKComponentHostingView *view = [[self class] hostingView:{}];
[view updateModel:[[CKComponentHostingViewTestModel alloc] initWithColor:[UIColor redColor] size:RCComponentSize::fromCGSize(CGSizeMake(50, 50))]
mode:CKUpdateModeSynchronous];
[view layoutIfNeeded];
UIView *componentView = [view.containerView.subviews firstObject];
XCTAssertEqualObjects(componentView.backgroundColor, [UIColor redColor], @"Expected component view to become red");
}
- (void)testEventuallyUpdatesViewOnAsynchronousModelChange
{
CKComponentHostingView *view = [[self class] hostingView:{}];
[view updateModel:[[CKComponentHostingViewTestModel alloc] initWithColor:[UIColor redColor] size:RCComponentSize::fromCGSize(CGSizeMake(50, 50))]
mode:CKUpdateModeAsynchronous];
[view layoutIfNeeded];
UIView *componentView = [view.containerView.subviews firstObject];
XCTAssertTrue(CKRunRunLoopUntilBlockIsTrue(^{
[view layoutIfNeeded];
return [componentView.backgroundColor isEqual:[UIColor redColor]];
}));
}
- (void)testInformsDelegateSizeIsInvalidatedOnModelChange
{
CKComponentHostingView *view = [[self class] hostingView:{}];
view.delegate = self;
[view updateModel:[[CKComponentHostingViewTestModel alloc] initWithColor:[UIColor orangeColor] size:RCComponentSize::fromCGSize(CGSizeMake(75, 75))]
mode:CKUpdateModeSynchronous];
XCTAssertTrue(_calledSizeDidInvalidate);
}
- (void)testInformsDelegateSizeIsInvalidatedOnContextChange
{
CKComponentHostingView *view = [[self class] hostingView:{}];
view.delegate = self;
[view updateContext:@"foo" mode:CKUpdateModeSynchronous];
XCTAssertTrue(_calledSizeDidInvalidate);
}
- (void)testInformsDelegateSizeIsInvalidatedOnAsynchronousUpdate
{
CKComponentHostingView *view = [[self class] hostingView:{}];
view.delegate = self;
[view updateContext:@"foo" mode:CKUpdateModeAsynchronous];
XCTAssertTrue(CKRunRunLoopUntilBlockIsTrue(^BOOL{
return _calledSizeDidInvalidate;
}));
}
- (void)testUpdateWithEmptyBoundsMountLayout
{
CKComponentHostingViewTestModel *model = [[CKComponentHostingViewTestModel alloc] initWithColor:[UIColor orangeColor] size:RCComponentSize::fromCGSize(CGSizeMake(50, 50))];
auto const view = [CKComponentHostingViewTests makeHostingView:{}];
[view updateModel:model mode:CKUpdateModeSynchronous];
[view layoutIfNeeded];
XCTAssertEqual([view.containerView.subviews count], 1u, @"Expect the component is mounted with empty bounds");
}
- (void)testComponentControllerReceivesInvalidateEventDuringDeallocation
{
CKLifecycleTestComponent *testComponent = nil;
@autoreleasepool {
CKComponentHostingView *view = [[self class] hostingView:{}];
[view updateContext:@"foo" mode:CKUpdateModeSynchronous];
testComponent = (CKLifecycleTestComponent *)view.mountedLayout.component;
}
XCTAssertTrue(testComponent.controller.calledInvalidateController,
@"Expected component controller to get invalidation event");
}
- (void)testComponentControllerReceivesDidInit
{
CKComponentHostingView *view = [[self class] hostingView:{}];
CKLifecycleTestComponent *testComponent = (CKLifecycleTestComponent *)view.mountedLayout.component;
XCTAssertTrue(testComponent.controller.calledDidInit, @"Expected component controller to get did init event");
}
- (void)testComponentControllerReceivesInvalidateEventDuringDeallocationEvenWhenParentIsStillPresent
{
CKComponentHostingView *view = [[self class] hostingView:{
.wrapperType = CKComponentHostingViewWrapperTypeTestComponent,
}];
auto const testComponent = (CKEmbeddedTestComponent *)view.mountedLayout.component;
auto const testLifecyleComponent = testComponent.lifecycleTestComponent;
[testComponent setLifecycleTestComponentIsHidden:YES];
[view layoutIfNeeded];
XCTAssertTrue(testLifecyleComponent.controller.calledInvalidateController, @"Expected component controller to get invalidation event");
}
- (void)testComponentControllerReceivesDidPrepareLayoutForComponent
{
CKLifecycleTestComponent *testComponent = nil;
CKComponentHostingView *view = [[self class] hostingView:{}];
[view updateContext:@"foo" mode:CKUpdateModeSynchronous];
testComponent = (CKLifecycleTestComponent *)view.mountedLayout.component;
XCTAssertTrue(testComponent.controller.calledDidPrepareLayoutForComponent,
@"Expected component controller to get did attach component");
}
- (void)testAllowTapPassthroughOn
{
// We embed this in a flexbox which allows the view to stay at its natural size
// while still allowing the host to grow. This allows us to do our hit testing
// properly below...
CKComponentHostingView *view = [[self class] hostingView:{
.allowTapPassthrough = YES,
.wrapperType = CKComponentHostingViewWrapperTypeFlexbox,
}];
[view layoutIfNeeded];
// this point should hit the component
UIView *const shouldHit = [view hitTest:CGPointMake(5, 5) withEvent:nil];
XCTAssertNotNil(shouldHit, @"When allowTapPassthrough is YES, hitTest should return nil");
// this one misses
UIView *const shouldMiss = [view hitTest:CGPointMake(55, 5) withEvent:nil];
XCTAssertNil(shouldMiss, @"When allowTapPassthrough is YES, hitTest should return nil");
}
- (void)testAllowTapPassthroughOff
{
// We embed this in a flexbox which allows the view to stay at its natural size
// while still allowing the host to grow. This allows us to do our hit testing
// properly below...
CKComponentHostingView *view = [[self class] hostingView:{
.wrapperType = CKComponentHostingViewWrapperTypeFlexbox,
}];
[view layoutIfNeeded];
// this should return the root view
UIView *const shouldBeRoot = [view hitTest:CGPointMake(55, 5) withEvent:nil];
XCTAssertTrue(shouldBeRoot == view.containerView, @"hitTest should return the hosting view or root view");
}
- (void)testSizeCache_CachedSizeIsUsedIfConstrainedSizesAreSame
{
CKComponentHostingView *view = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
}];
const auto constrainedSize = CGSizeMake(100, 100);
[view sizeThatFits:constrainedSize];
[view sizeThatFits:constrainedSize];
XCTAssertEqual(_analyticsListenerSpy.willLayoutComponentTreeHitCount, 2);
}
- (void)testSizeCache_CachedSizeIsNotUsedIfConstrainedSizesAreDifferent
{
CKComponentHostingView *view = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.sizeRangeProvider = [CKComponentFlexibleSizeRangeProvider providerWithFlexibility:CKComponentSizeRangeFlexibilityNone],
}];
const auto constrainedSize1 = CGSizeMake(100, 100);
const auto constrainedSize2 = CGSizeMake(200, 200);
[view sizeThatFits:constrainedSize1];
[view sizeThatFits:constrainedSize2];
XCTAssertEqual(_analyticsListenerSpy.willLayoutComponentTreeHitCount, 3);
}
- (void)testSizeCache_CacheSizeIsNotUsedIfComponentIsUpdated
{
CKComponentHostingView *view = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
}];
const auto constrainedSize = CGSizeMake(100, 100);
[view sizeThatFits:constrainedSize];
[view updateModel:nil mode:CKUpdateModeSynchronous];
[view sizeThatFits:constrainedSize];
XCTAssertEqual(_analyticsListenerSpy.willLayoutComponentTreeHitCount, 3);
}
- (void)testUpdateModel_ComponentIsReused
{
CKComponentHostingView *view = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.wrapperType = CKComponentHostingViewWrapperTypeRenderComponent,
}];
const auto c1 = (CKRenderLifecycleTestComponent *)view.mountedLayout.component;
XCTAssertTrue(c1.isRenderFunctionCalled);
[view updateModel:[[CKComponentHostingViewTestModel alloc]
initWithColor:nil
size:{}
wrapperType:CKComponentHostingViewWrapperTypeRenderComponent
willGenerateComponent:nil]
mode:CKUpdateModeSynchronous];
[view layoutIfNeeded];
const auto c2 = (CKRenderLifecycleTestComponent *)view.mountedLayout.component;
XCTAssertFalse(c2.isRenderFunctionCalled);
}
- (void)testReload_ComponentIsNotReused
{
CKComponentHostingView *view = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.wrapperType = CKComponentHostingViewWrapperTypeRenderComponent,
}];
const auto c1 = (CKRenderLifecycleTestComponent *)view.mountedLayout.component;
XCTAssertTrue(c1.isRenderFunctionCalled);
[view reloadWithMode:CKUpdateModeSynchronous];
[view layoutIfNeeded];
const auto c2 = (CKRenderLifecycleTestComponent *)view.mountedLayout.component;
XCTAssertTrue(c2.isRenderFunctionCalled);
}
#pragma mark - CKComponentHostingViewDelegate
- (void)componentHostingViewDidInvalidateSize:(CKComponentHostingView *)hostingView
{
_calledSizeDidInvalidate = YES;
}
- (void)test_WhenMountsLayout_ReportsWillCollectAnimationsEvent
{
// This has a side-effect of mounting the test layout
[[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
}];
XCTAssertEqual(_analyticsListenerSpy.willCollectAnimationsHitCount, 1);
}
- (void)test_WhenMountsLayout_ReportsDidCollectAnimationsEvent
{
// This has a side-effect of mounting the test layout
[[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
}];
XCTAssertEqual(_analyticsListenerSpy.didCollectAnimationsHitCount, 1);
}
- (void)test_LayoutAndGenerationOfComponentAreOnMainThreadWhenAsyncUpdateIsTriggeredWithoutInitialSize
{
const auto hostingView = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.shouldUpdateModelAfterCreation = NO,
}];
[hostingView updateModel:nil mode:CKUpdateModeAsynchronous];
[hostingView layoutIfNeeded];
XCTAssertEqual(_analyticsListenerSpy.didLayoutComponentTreeHitCount, 1);
XCTAssertEqual(_analyticsListenerSpy.didMountComponentHitCount, 1);
}
- (void)test_LayoutAndGenerationOfComponentAreNotOnMainThreadWhenAsyncUpdateIsTriggeredWithInitialSize
{
const auto hostingView = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.shouldUpdateModelAfterCreation = NO,
.initialSize = CGSizeMake(100, 100),
}];
[hostingView updateModel:nil mode:CKUpdateModeAsynchronous];
[hostingView layoutIfNeeded];
XCTAssertEqual(_analyticsListenerSpy.didLayoutComponentTreeHitCount, 0);
XCTAssertEqual(_analyticsListenerSpy.didMountComponentHitCount, 0);
XCTAssertTrue(CKRunRunLoopUntilBlockIsTrue(^BOOL{
[hostingView layoutIfNeeded];
return _analyticsListenerSpy.didLayoutComponentTreeHitCount == 1
&& _analyticsListenerSpy.didMountComponentHitCount == 1;
}));
}
- (void)test_LayoutAndGenerationOfComponentAreOnMainThreadWhenSyncUpdateIsTriggeredWithInitialSize
{
const auto hostingView = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.shouldUpdateModelAfterCreation = NO,
.initialSize = CGSizeMake(100, 100),
}];
[hostingView updateModel:nil mode:CKUpdateModeSynchronous];
[hostingView layoutIfNeeded];
XCTAssertEqual(_analyticsListenerSpy.didLayoutComponentTreeHitCount, 1);
XCTAssertEqual(_analyticsListenerSpy.didMountComponentHitCount, 1);
}
- (void)test_CurrentTraitCollectionIsCorrectInBackgroundQueueWhenTraitCollectionIsSet
{
if (@available(iOS 13.0, tvOS 13.0, *)) {
__block UITraitCollection *currentTraitCollection = nil;
const auto hostingView = [[self class] hostingView:{
.analyticsListener = _analyticsListenerSpy,
.shouldUpdateModelAfterCreation = YES,
.initialSize = CGSizeMake(100, 100),
.willGenerateComponent = ^{
currentTraitCollection = [UITraitCollection currentTraitCollection];
},
}];
XCTAssertEqual(currentTraitCollection.userInterfaceIdiom, hostingView.traitCollection.userInterfaceIdiom);
[hostingView updateContext:nil mode:CKUpdateModeAsynchronous];
CKRunRunLoopUntilBlockIsTrue(^BOOL{
return _analyticsListenerSpy.didBuildComponentTreeHitCount == 2;
});
XCTAssertEqual(currentTraitCollection.userInterfaceIdiom, hostingView.traitCollection.userInterfaceIdiom);
}
}
@end
@interface CKComponentHostingViewTests_ComponentProviderFunction : CKComponentHostingViewTests
@end
@implementation CKComponentHostingViewTests_ComponentProviderFunction
+ (CKComponentHostingView *)makeHostingView:(const CKComponentHostingViewConfiguration &)options
{
return [[CKComponentHostingView alloc] initWithComponentProviderFunc:CKComponentTestComponentProviderFunc
sizeRangeProvider:options.sizeRangeProvider ?: [CKComponentFlexibleSizeRangeProvider providerWithFlexibility:CKComponentSizeRangeFlexibleWidthAndHeight]
componentPredicates:{}
componentControllerPredicates:{}
analyticsListener:options.analyticsListener
options:{
.allowTapPassthrough = options.allowTapPassthrough,
.initialSize = options.initialSize,
}];
}
+ (NSString *)componentProviderIdentifier
{
return [NSString stringWithFormat:@"%p", CKComponentTestComponentProviderFunc];
}
@end