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