// // ASTableViewTests.mm // Texture // // Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. // Changes after 4/13/2017 are: Copyright (c) Pinterest, Inc. All rights reserved. // Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 // #import #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdocumentation" #import #pragma clang diagnostic pop #import #import #import #import #import #import #import #import #import "ASXCTExtensions.h" #define NumberOfSections 10 #define NumberOfReloadIterations 50 @interface ASTestDataController : ASDataController @property (nonatomic) int numberOfAllNodesRelayouts; @end @implementation ASTestDataController - (void)relayoutAllNodesWithInvalidationBlock:(nullable void (^)())invalidationBlock { _numberOfAllNodesRelayouts++; [super relayoutAllNodesWithInvalidationBlock:invalidationBlock]; } @end @interface UITableView (Testing) // This will start recording all editing calls to UITableView // into the provided array. // Make sure to call [UITableView deswizzleInstanceMethods] to reset this. + (void)as_recordEditingCallsIntoArray:(NSMutableArray *)selectors; @end @interface ASTestTableView : ASTableView @property (nonatomic) void (^willDeallocBlock)(ASTableView *tableView); @end @implementation ASTestTableView - (instancetype)__initWithFrame:(CGRect)frame style:(UITableViewStyle)style { return [super _initWithFrame:frame style:style dataControllerClass:[ASTestDataController class] owningNode:nil eventLog:nil]; } - (ASTestDataController *)testDataController { return (ASTestDataController *)self.dataController; } - (void)dealloc { if (_willDeallocBlock) { _willDeallocBlock(self); } } @end @interface ASTableViewTestDelegate : NSObject @property (nonatomic) void (^willDeallocBlock)(ASTableViewTestDelegate *delegate); @property (nonatomic) CGFloat headerHeight; @property (nonatomic) CGFloat footerHeight; @end @implementation ASTableViewTestDelegate - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return 0; } - (ASCellNode *)tableView:(ASTableView *)tableView nodeForRowAtIndexPath:(NSIndexPath *)indexPath { return nil; } - (ASCellNodeBlock)tableView:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath { return nil; } - (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { return _footerHeight; } - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { return _headerHeight; } - (void)dealloc { if (_willDeallocBlock) { _willDeallocBlock(self); } } @end @interface ASTestTextCellNode : ASTextCellNode /** Calculated by counting how many times -layoutSpecThatFits: is called on the main thread. */ @property (nonatomic) int numberOfLayoutsOnMainThread; @end @implementation ASTestTextCellNode - (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize { if ([NSThread isMainThread]) { _numberOfLayoutsOnMainThread++; } return [super layoutSpecThatFits:constrainedSize]; } @end @interface ASTableViewFilledDataSource : NSObject @property (nonatomic) BOOL usesSectionIndex; @property (nonatomic) NSInteger numberOfSections; @property (nonatomic) NSInteger rowsPerSection; @property (nonatomic, nullable) ASCellNodeBlock(^nodeBlockForItem)(NSIndexPath *); @end @implementation ASTableViewFilledDataSource - (instancetype)init { self = [super init]; if (self != nil) { _numberOfSections = NumberOfSections; _rowsPerSection = 20; } return self; } - (BOOL)respondsToSelector:(SEL)aSelector { if (aSelector == @selector(sectionIndexTitlesForTableView:) || aSelector == @selector(tableView:sectionForSectionIndexTitle:atIndex:)) { return _usesSectionIndex; } else { return [super respondsToSelector:aSelector]; } } - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return _numberOfSections; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return _rowsPerSection; } - (ASCellNode *)tableView:(ASTableView *)tableView nodeForRowAtIndexPath:(NSIndexPath *)indexPath { ASTestTextCellNode *textCellNode = [ASTestTextCellNode new]; textCellNode.text = indexPath.description; return textCellNode; } - (ASCellNodeBlock)tableView:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath { if (_nodeBlockForItem) { return _nodeBlockForItem(indexPath); } return ^{ ASTestTextCellNode *textCellNode = [ASTestTextCellNode new]; textCellNode.text = [NSString stringWithFormat:@"{%d, %d}", (int)indexPath.section, (int)indexPath.row]; textCellNode.backgroundColor = [UIColor whiteColor]; return textCellNode; }; } - (nullable NSArray *)sectionIndexTitlesForTableView:(UITableView *)tableView { return @[ @"A", @"B", @"C" ]; } - (NSInteger)tableView:(UITableView *)tableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index { return 0; } @end @interface ASTableViewFilledDelegate : NSObject @end @implementation ASTableViewFilledDelegate - (ASSizeRange)tableView:(ASTableView *)tableView constrainedSizeForRowAtIndexPath:(NSIndexPath *)indexPath { return ASSizeRangeMake(CGSizeMake(10, 42)); } @end @interface ASTableViewTests : XCTestCase @property (nonatomic, retain) ASTableView *testTableView; @end @implementation ASTableViewTests - (void)testDataSourceImplementsNecessaryMethods { ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectMake(0, 0, 100, 400) style:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = (ASTableViewFilledDataSource *)[NSObject new]; XCTAssertThrows((tableView.asyncDataSource = dataSource)); dataSource = [ASTableViewFilledDataSource new]; XCTAssertNoThrow((tableView.asyncDataSource = dataSource)); } - (void)testConstrainedSizeForRowAtIndexPath { // Initial width of the table view is non-zero and all nodes are measured with this size. // Any subsequent size change must trigger a relayout. // Width and height are swapped so that a later size change will simulate a rotation ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectMake(0, 0, 100, 400) style:UITableViewStylePlain]; ASTableViewFilledDelegate *delegate = [ASTableViewFilledDelegate new]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; tableView.asyncDelegate = delegate; tableView.asyncDataSource = dataSource; [tableView reloadData]; [tableView waitUntilAllUpdatesAreCommitted]; [tableView setNeedsLayout]; [tableView layoutIfNeeded]; CGFloat separatorHeight = 1.0 / ASScreenScale(); for (int section = 0; section < NumberOfSections; section++) { for (int row = 0; row < [tableView numberOfRowsInSection:section]; row++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section]; CGRect rect = [tableView rectForRowAtIndexPath:indexPath]; XCTAssertEqual(rect.size.width, 100); // specified width should be ignored for table XCTAssertEqual(rect.size.height, 42 + separatorHeight); } } } // TODO: Convert this to ARC. - (void)DISABLED_testTableViewDoesNotRetainItselfAndDelegate { ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectZero style:UITableViewStylePlain]; __block BOOL tableViewDidDealloc = NO; tableView.willDeallocBlock = ^(ASTableView *v){ tableViewDidDealloc = YES; }; ASTableViewTestDelegate *delegate = [[ASTableViewTestDelegate alloc] init]; __block BOOL delegateDidDealloc = NO; delegate.willDeallocBlock = ^(ASTableViewTestDelegate *d){ delegateDidDealloc = YES; }; tableView.asyncDataSource = delegate; tableView.asyncDelegate = delegate; // [delegate release]; XCTAssertTrue(delegateDidDealloc, @"unexpected delegate lifetime:%@", delegate); // XCTAssertNoThrow([tableView release], @"unexpected exception when deallocating table view:%@", tableView); XCTAssertTrue(tableViewDidDealloc, @"unexpected table view lifetime:%@", tableView); } - (NSIndexSet *)randomIndexSet { NSInteger randA = arc4random_uniform(NumberOfSections - 1); NSInteger randB = arc4random_uniform(NumberOfSections - 1); return [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(MIN(randA, randB), MAX(randA, randB) - MIN(randA, randB))]; } - (NSArray *)randomIndexPathsExisting:(BOOL)existing rowCount:(NSInteger)rowCount { NSMutableArray *indexPaths = [NSMutableArray array]; [[self randomIndexSet] enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) { NSIndexPath *sectionIndex = [[NSIndexPath alloc] initWithIndex:idx]; for (NSUInteger i = (existing ? 0 : rowCount); i < (existing ? rowCount : rowCount * 2); i++) { // Maximize evility by sporadically skipping indicies 1/3rd of the time, but only if reloading existing rows if (existing && arc4random_uniform(2) == 0) { continue; } NSIndexPath *indexPath = [sectionIndex indexPathByAddingIndex:i]; [indexPaths addObject:indexPath]; } }]; return indexPaths; } - (void)DISABLED_testReloadData { // Keep the viewport moderately sized so that new cells are loaded on scrolling ASTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectMake(0, 0, 100, 500) style:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; tableView.asyncDelegate = dataSource; tableView.asyncDataSource = dataSource; XCTestExpectation *reloadDataExpectation = [self expectationWithDescription:@"reloadData"]; [tableView reloadDataWithCompletion:^{ NSLog(@"*** Reload Complete ***"); [reloadDataExpectation fulfill]; }]; [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) { if (error) { XCTFail(@"Expectation failed: %@", error); } }]; for (int i = 0; i < NumberOfReloadIterations; ++i) { UITableViewRowAnimation rowAnimation = (arc4random_uniform(2) == 0 ? UITableViewRowAnimationMiddle : UITableViewRowAnimationNone); BOOL animatedScroll = (arc4random_uniform(2) == 0 ? YES : NO); BOOL reloadRowsInsteadOfSections = (arc4random_uniform(2) == 0 ? YES : NO); NSTimeInterval runLoopDelay = ((arc4random_uniform(2) == 0) ? (1.0 / (1 + arc4random_uniform(500))) : 0); BOOL useBeginEndUpdates = (arc4random_uniform(3) == 0 ? YES : NO); // instrument our instrumentation ;) //NSLog(@"Iteration %03d: %@|%@|%@|%@|%g", i, (rowAnimation == UITableViewRowAnimationNone) ? @"NONE " : @"MIDDLE", animatedScroll ? @"ASCR" : @" ", reloadRowsInsteadOfSections ? @"ROWS" : @"SECS", useBeginEndUpdates ? @"BEGEND" : @" ", runLoopDelay); if (useBeginEndUpdates) { [tableView beginUpdates]; } if (reloadRowsInsteadOfSections) { NSArray *indexPaths = [self randomIndexPathsExisting:YES rowCount:dataSource.rowsPerSection]; //NSLog(@"reloading rows: %@", indexPaths); [tableView reloadRowsAtIndexPaths:indexPaths withRowAnimation:rowAnimation]; } else { NSIndexSet *sections = [self randomIndexSet]; //NSLog(@"reloading sections: %@", sections); [tableView reloadSections:sections withRowAnimation:rowAnimation]; } [tableView setContentOffset:CGPointMake(0, arc4random_uniform(tableView.contentSize.height - tableView.bounds.size.height)) animated:animatedScroll]; if (runLoopDelay > 0) { // Run other stuff on the main queue for between 2ms and 1000ms. [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:runLoopDelay]]; } if (useBeginEndUpdates) { [tableView endUpdates]; } } } - (void)testRelayoutAllNodesWithNonZeroSizeInitially { // Initial width of the table view is non-zero and all nodes are measured with this size. // Any subsequence size change must trigger a relayout. CGSize tableViewFinalSize = CGSizeMake(100, 500); // Width and height are swapped so that a later size change will simulate a rotation ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectMake(0, 0, tableViewFinalSize.height, tableViewFinalSize.width) style:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; tableView.asyncDelegate = dataSource; tableView.asyncDataSource = dataSource; [tableView layoutIfNeeded]; XCTAssertEqual(tableView.testDataController.numberOfAllNodesRelayouts, 0); [self triggerSizeChangeAndAssertRelayoutAllNodesForTableView:tableView newSize:tableViewFinalSize]; } - (void)testRelayoutVisibleRowsWhenEditingModeIsChanged { CGSize tableViewSize = CGSizeMake(100, 500); ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectMake(0, 0, tableViewSize.width, tableViewSize.height) style:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; // Currently this test requires that the text in the cell node fills the // visible width, so we use the long description for the index path. dataSource.nodeBlockForItem = ^(NSIndexPath *indexPath) { return (ASCellNodeBlock)^{ ASTestTextCellNode *textCellNode = [[ASTestTextCellNode alloc] init]; textCellNode.text = indexPath.description; return textCellNode; }; }; tableView.asyncDelegate = dataSource; tableView.asyncDataSource = dataSource; [self triggerFirstLayoutMeasurementForTableView:tableView]; NSArray *visibleNodes = [tableView visibleNodes]; XCTAssertGreaterThan(visibleNodes.count, 0); // Cause table view to enter editing mode. // Visibile nodes should be re-measured on main thread with the new (smaller) content view width. // Other nodes are untouched. XCTestExpectation *relayoutAfterEnablingEditingExpectation = [self expectationWithDescription:@"relayoutAfterEnablingEditing"]; [tableView beginUpdates]; [tableView setEditing:YES]; [tableView endUpdatesAnimated:YES completion:^(BOOL completed) { for (int section = 0; section < NumberOfSections; section++) { for (int row = 0; row < dataSource.rowsPerSection; row++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section]; ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath]; if ([visibleNodes containsObject:node]) { XCTAssertEqual(node.numberOfLayoutsOnMainThread, 1); XCTAssertLessThan(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width); } else { XCTAssertEqual(node.numberOfLayoutsOnMainThread, 0); XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width); } } } [relayoutAfterEnablingEditingExpectation fulfill]; }]; [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) { if (error) { XCTFail(@"Expectation failed: %@", error); } }]; // Cause table view to leave editing mode. // Visibile nodes should be re-measured again. // All nodes should have max constrained width equals to the table view width. XCTestExpectation *relayoutAfterDisablingEditingExpectation = [self expectationWithDescription:@"relayoutAfterDisablingEditing"]; [tableView beginUpdates]; [tableView setEditing:NO]; [tableView endUpdatesAnimated:YES completion:^(BOOL completed) { for (int section = 0; section < NumberOfSections; section++) { for (int row = 0; row < dataSource.rowsPerSection; row++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section]; ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath]; BOOL visible = [visibleNodes containsObject:node]; XCTAssertEqual(node.numberOfLayoutsOnMainThread, visible ? 2: 0); XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width); } } [relayoutAfterDisablingEditingExpectation fulfill]; }]; [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) { if (error) { XCTFail(@"Expectation failed: %@", error); } }]; } - (void)DISABLED_testRelayoutRowsAfterEditingModeIsChangedAndTheyBecomeVisible { CGSize tableViewSize = CGSizeMake(100, 500); ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectMake(0, 0, tableViewSize.width, tableViewSize.height) style:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; tableView.asyncDelegate = dataSource; tableView.asyncDataSource = dataSource; [self triggerFirstLayoutMeasurementForTableView:tableView]; // Cause table view to enter editing mode and then scroll to the bottom. // The last node should be re-measured on main thread with the new (smaller) content view width. NSIndexPath *lastRowIndexPath = [NSIndexPath indexPathForRow:(dataSource.rowsPerSection - 1) inSection:(NumberOfSections - 1)]; XCTestExpectation *relayoutExpectation = [self expectationWithDescription:@"relayout"]; [tableView beginUpdates]; [tableView setEditing:YES]; [tableView setContentOffset:CGPointMake(0, CGFLOAT_MAX) animated:YES]; [tableView endUpdatesAnimated:YES completion:^(BOOL completed) { ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:lastRowIndexPath]; XCTAssertEqual(node.numberOfLayoutsOnMainThread, 1); XCTAssertLessThan(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width); [relayoutExpectation fulfill]; }]; [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) { if (error) { XCTFail(@"Expectation failed: %@", error); } }]; } - (void)testIndexPathForNode { CGSize tableViewSize = CGSizeMake(100, 500); ASTestTableView *tableView = [[ASTestTableView alloc] initWithFrame:CGRectMake(0, 0, tableViewSize.width, tableViewSize.height) style:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; tableView.asyncDelegate = dataSource; tableView.asyncDataSource = dataSource; [tableView reloadDataWithCompletion:^{ for (NSUInteger i = 0; i < NumberOfSections; i++) { for (NSUInteger j = 0; j < dataSource.rowsPerSection; j++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:j inSection:i]; ASCellNode *cellNode = [tableView nodeForRowAtIndexPath:indexPath]; NSIndexPath *reportedIndexPath = [tableView indexPathForNode:cellNode]; XCTAssertEqual(indexPath.row, reportedIndexPath.row); } } self.testTableView = nil; }]; } - (void)triggerFirstLayoutMeasurementForTableView:(ASTableView *)tableView{ XCTestExpectation *reloadDataExpectation = [self expectationWithDescription:@"reloadData"]; [tableView reloadDataWithCompletion:^{ for (int section = 0; section < NumberOfSections; section++) { for (int row = 0; row < [tableView numberOfRowsInSection:section]; row++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section]; ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath]; XCTAssertEqual(node.numberOfLayoutsOnMainThread, 0); XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableView.frame.size.width); } } [reloadDataExpectation fulfill]; }]; [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) { if (error) { XCTFail(@"Expectation failed: %@", error); } }]; [tableView setNeedsLayout]; [tableView layoutIfNeeded]; [tableView waitUntilAllUpdatesAreCommitted]; } - (void)triggerSizeChangeAndAssertRelayoutAllNodesForTableView:(ASTestTableView *)tableView newSize:(CGSize)newSize { XCTestExpectation *nodesMeasuredUsingNewConstrainedSizeExpectation = [self expectationWithDescription:@"nodesMeasuredUsingNewConstrainedSize"]; [tableView beginUpdates]; CGRect frame = tableView.frame; frame.size = newSize; tableView.frame = frame; [tableView layoutIfNeeded]; [tableView endUpdatesAnimated:NO completion:^(BOOL completed) { XCTAssertEqual(tableView.testDataController.numberOfAllNodesRelayouts, 1); for (int section = 0; section < NumberOfSections; section++) { for (int row = 0; row < [tableView numberOfRowsInSection:section]; row++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section]; ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath]; XCTAssertLessThanOrEqual(node.numberOfLayoutsOnMainThread, 1); XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, newSize.width); } } [nodesMeasuredUsingNewConstrainedSizeExpectation fulfill]; }]; [self waitForExpectationsWithTimeout:5 handler:^(NSError *error) { if (error) { XCTFail(@"Expectation failed: %@", error); } }]; } /** * This may seem silly, but we had issues where the runtime sometimes wouldn't correctly report * conformances declared on categories. */ - (void)testThatTableNodeConformsToExpectedProtocols { ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; XCTAssert([node conformsToProtocol:@protocol(ASRangeControllerUpdateRangeProtocol)]); } - (void)testThatInitialDataLoadHappensInOneShot { NSMutableArray *selectors = [NSMutableArray array]; ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; node.frame = CGRectMake(0, 0, 100, 100); node.dataSource = dataSource; node.delegate = dataSource; [UITableView as_recordEditingCallsIntoArray:selectors]; XCTAssertGreaterThan(node.numberOfSections, 0); [node waitUntilAllUpdatesAreProcessed]; XCTAssertGreaterThan(node.view.numberOfSections, 0); // The first reloadData call helps prevent UITableView from calling it multiple times while ASDataController is working. // The second reloadData call is the real one. NSArray *expectedSelectors = @[ NSStringFromSelector(@selector(reloadData)), NSStringFromSelector(@selector(reloadData)) ]; XCTAssertEqualObjects(selectors, expectedSelectors); [UITableView deswizzleAllInstanceMethods]; } - (void)testThatReloadDataHappensInOneShot { NSMutableArray *selectors = [NSMutableArray array]; ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; node.frame = CGRectMake(0, 0, 100, 100); node.dataSource = dataSource; node.delegate = dataSource; // Load initial data. XCTAssertGreaterThan(node.numberOfSections, 0); [node waitUntilAllUpdatesAreProcessed]; XCTAssertGreaterThan(node.view.numberOfSections, 0); // Reload data. [UITableView as_recordEditingCallsIntoArray:selectors]; [node reloadData]; [node waitUntilAllUpdatesAreProcessed]; // Assert that the beginning of the call pattern is correct. // There is currently noise that comes after that we will allow for this test. NSArray *expectedSelectors = @[ NSStringFromSelector(@selector(reloadData)) ]; XCTAssertEqualObjects(selectors, expectedSelectors); [UITableView deswizzleAllInstanceMethods]; } /** * This tests an issue where, if the table is loaded before the first layout pass, * the nodes are first measured with a constrained width of 0 which isn't ideal. */ - (void)testThatNodeConstrainedSizesAreCorrectIfReloadIsPreempted { ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; CGFloat cellWidth = 320; node.frame = CGRectMake(0, 0, cellWidth, 480); node.dataSource = dataSource; node.delegate = dataSource; // Trigger data load BEFORE first layout pass, to ensure constrained size is correct. XCTAssertGreaterThan(node.numberOfSections, 0); [node waitUntilAllUpdatesAreProcessed]; ASSizeRange expectedSizeRange = ASSizeRangeMake(CGSizeMake(cellWidth, 0)); expectedSizeRange.max.height = CGFLOAT_MAX; for (NSInteger i = 0; i < node.numberOfSections; i++) { for (NSInteger j = 0; j < [node numberOfRowsInSection:i]; j++) { NSIndexPath *indexPath = [NSIndexPath indexPathForItem:j inSection:i]; ASTestTextCellNode *cellNode = (id)[node nodeForRowAtIndexPath:indexPath]; ASXCTAssertEqualSizeRanges(cellNode.constrainedSizeForCalculatedLayout, expectedSizeRange); XCTAssertEqual(cellNode.numberOfLayoutsOnMainThread, 0); } } } - (void)testSectionIndexHandling { ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; dataSource.usesSectionIndex = YES; node.frame = CGRectMake(0, 0, 320, 480); node.dataSource = dataSource; node.delegate = dataSource; // Trigger data load XCTAssertGreaterThan(node.numberOfSections, 0); XCTAssertGreaterThan([node numberOfRowsInSection:0], 0); // UITableView's section index view is added only after some rows were inserted to the table. // All nodes loaded and measured during the initial reloadData used an outdated constrained width (i.e full width: 320). // So we need to force a new layout pass so that the table will pick up a new constrained size and apply to its node. [node setNeedsLayout]; [node.view layoutIfNeeded]; [node waitUntilAllUpdatesAreProcessed]; UITableViewCell *cell = [node.view cellForRowAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; XCTAssertNotNil(cell); CGFloat cellWidth = cell.contentView.frame.size.width; XCTAssert(cellWidth > 0 && cellWidth < 320, @"Expected cell width to be about 305. Width: %@", @(cellWidth)); ASSizeRange expectedSizeRange = ASSizeRangeMake(CGSizeMake(cellWidth, 0)); expectedSizeRange.max.height = CGFLOAT_MAX; for (NSInteger i = 0; i < node.numberOfSections; i++) { for (NSInteger j = 0; j < [node numberOfRowsInSection:i]; j++) { NSIndexPath *indexPath = [NSIndexPath indexPathForItem:j inSection:i]; ASTestTextCellNode *cellNode = (id)[node nodeForRowAtIndexPath:indexPath]; ASXCTAssertEqualSizeRanges(cellNode.constrainedSizeForCalculatedLayout, expectedSizeRange); // We will have to accept a relayout on main thread, since the index bar won't show // up until some of the cells are inserted. XCTAssertLessThanOrEqual(cellNode.numberOfLayoutsOnMainThread, 1); } } } - (void)testThatNilBatchUpdatesCanBeSubmitted { ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; // Passing nil blocks should not crash [node performBatchUpdates:nil completion:nil]; [node performBatchAnimated:NO updates:nil completion:nil]; } // https://github.com/facebook/AsyncDisplayKit/issues/2252#issuecomment-263689979 - (void)testIssue2252 { // Hard-code an iPhone 7 screen. There's something particular about this geometry that causes the issue to repro. UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStyleGrouped]; node.frame = window.bounds; ASTableViewTestDelegate *del = [[ASTableViewTestDelegate alloc] init]; del.headerHeight = 32; del.footerHeight = 0.01; node.delegate = del; ASTableViewFilledDataSource *ds = [[ASTableViewFilledDataSource alloc] init]; ds.rowsPerSection = 1; node.dataSource = ds; ASViewController *vc = [[ASViewController alloc] initWithNode:node]; UITabBarController *tabCtrl = [[UITabBarController alloc] init]; tabCtrl.viewControllers = @[ vc ]; tabCtrl.tabBar.translucent = NO; window.rootViewController = tabCtrl; [window makeKeyAndVisible]; [window layoutIfNeeded]; [node waitUntilAllUpdatesAreProcessed]; XCTAssertEqual(node.view.numberOfSections, NumberOfSections); ASXCTAssertEqualRects(CGRectMake(0, 32, 375, 44), [node rectForRowAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]], @"This text requires very specific geometry. The rect for the first row should match up."); __unused XCTestExpectation *e = [self expectationWithDescription:@"Did a bunch of rounds of updates."]; NSInteger totalCount = 20; __block NSInteger count = 0; dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 0.2 * NSEC_PER_SEC, 0.01 * NSEC_PER_SEC); dispatch_source_set_event_handler(timer, ^{ [node performBatchUpdates:^{ [node reloadSections:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, NumberOfSections)] withRowAnimation:UITableViewRowAnimationNone]; } completion:^(BOOL finished) { if (++count == totalCount) { dispatch_cancel(timer); dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [e fulfill]; }); } }]; }); dispatch_resume(timer); [self waitForExpectationsWithTimeout:60 handler:nil]; } - (void)testThatInvalidUpdateExceptionReasonContainsDataSourceClassName { ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStyleGrouped]; node.bounds = CGRectMake(0, 0, 100, 100); ASTableViewFilledDataSource *ds = [[ASTableViewFilledDataSource alloc] init]; node.dataSource = ds; // Force node to load initial data. [node.view layoutIfNeeded]; // Submit an invalid update, ensure exception name matches and that data source is included in the reason. @try { [node deleteSections:[NSIndexSet indexSetWithIndex:1000] withRowAnimation:UITableViewRowAnimationNone]; XCTFail(@"Expected validation to fail."); } @catch (NSException *e) { XCTAssertEqual(e.name, ASCollectionInvalidUpdateException); XCTAssert([e.reason containsString:NSStringFromClass([ds class])], @"Expected validation reason to contain the data source class name. Got:\n%@", e.reason); } } - (void)testAutomaticallyAdjustingContentOffset { ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; node.automaticallyAdjustsContentOffset = YES; node.bounds = CGRectMake(0, 0, 100, 100); ASTableViewFilledDataSource *ds = [[ASTableViewFilledDataSource alloc] init]; node.dataSource = ds; [node.view layoutIfNeeded]; [node waitUntilAllUpdatesAreProcessed]; CGFloat rowHeight = [node.view rectForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]].size.height; // Scroll to row (0,1) + 10pt node.contentOffset = CGPointMake(0, rowHeight + 10); [node performBatchAnimated:NO updates:^{ // Delete row 0 from all sections. // This is silly but it's a consequence of how ASTableViewFilledDataSource is built. ds.rowsPerSection -= 1; for (NSInteger i = 0; i < NumberOfSections; i++) { [node deleteRowsAtIndexPaths:@[ [NSIndexPath indexPathForItem:0 inSection:i]] withRowAnimation:UITableViewRowAnimationAutomatic]; } } completion:nil]; [node waitUntilAllUpdatesAreProcessed]; // Now that row (0,0) is deleted, we should have slid up to be at just 10 // i.e. we should have subtracted the deleted row height from our content offset. XCTAssertEqual(node.contentOffset.y, 10); } - (void)testTableViewReloadDoesReloadIfEditableTextNodeIsFirstResponder { ASEditableTextNode *editableTextNode = [[ASEditableTextNode alloc] init]; UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; ASTableNode *node = [[ASTableNode alloc] initWithStyle:UITableViewStyleGrouped]; node.frame = window.bounds; [window addSubnode:node]; ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new]; dataSource.rowsPerSection = 1; dataSource.numberOfSections = 1; // Currently this test requires that the text in the cell node fills the // visible width, so we use the long description for the index path. dataSource.nodeBlockForItem = ^(NSIndexPath *indexPath) { return (ASCellNodeBlock)^{ ASCellNode *cellNode = [[ASCellNode alloc] init]; cellNode.automaticallyManagesSubnodes = YES; cellNode.layoutSpecBlock = ^ASLayoutSpec * _Nonnull(__kindof ASDisplayNode * _Nonnull node, ASSizeRange constrainedSize) { return [ASInsetLayoutSpec insetLayoutSpecWithInsets:UIEdgeInsetsMake(10, 10, 10, 10) child:editableTextNode]; }; return cellNode; }; }; node.delegate = dataSource; node.dataSource = dataSource; // Reload the data for the initial load [node reloadData]; [node waitUntilAllUpdatesAreProcessed]; [node setNeedsLayout]; [node layoutIfNeeded]; // Set the textView as first responder [editableTextNode.textView becomeFirstResponder]; // Change data source count and try to reload a second time dataSource.rowsPerSection = 2; [node reloadData]; [node waitUntilAllUpdatesAreProcessed]; // Check that numberOfRows in section 0 is 2 XCTAssertEqual([node numberOfRowsInSection:0], 2); XCTAssertEqual([node.view numberOfRowsInSection:0], 2); } @end @implementation UITableView (Testing) + (void)as_recordEditingCallsIntoArray:(NSMutableArray *)selectors { [UITableView swizzleInstanceMethod:@selector(reloadData) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *) { JGOriginalImplementation(void); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; [UITableView swizzleInstanceMethod:@selector(beginUpdates) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *) { JGOriginalImplementation(void); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; [UITableView swizzleInstanceMethod:@selector(endUpdates) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *) { JGOriginalImplementation(void); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; [UITableView swizzleInstanceMethod:@selector(insertRowsAtIndexPaths:withRowAnimation:) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *, NSArray *indexPaths, UITableViewRowAnimation anim) { JGOriginalImplementation(void, indexPaths, anim); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; [UITableView swizzleInstanceMethod:@selector(deleteRowsAtIndexPaths:withRowAnimation:) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *, NSArray *indexPaths, UITableViewRowAnimation anim) { JGOriginalImplementation(void, indexPaths, anim); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; [UITableView swizzleInstanceMethod:@selector(insertSections:withRowAnimation:) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *, NSIndexSet *indexes, UITableViewRowAnimation anim) { JGOriginalImplementation(void, indexes, anim); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; [UITableView swizzleInstanceMethod:@selector(deleteSections:withRowAnimation:) withReplacement:JGMethodReplacementProviderBlock { return JGMethodReplacement(void, UITableView *, NSIndexSet *indexes, UITableViewRowAnimation anim) { JGOriginalImplementation(void, indexes, anim); [selectors addObject:NSStringFromSelector(_cmd)]; }; }]; } @end