Swiftgram/AsyncDisplayKitTests/ASTableViewTests.m
Adlai Holler c2ea7cfeac [Umbrella] ASCollectionView -> ASCollectionNode Migration, Separate Index Spaces (#2372)
* Separate dataSource & UIKit index spaces

Beef up our supplementary node support

Make the API way better

Go nuts

Add a unit test for UICollectionView's handling of reloadData inside batch updates

Wrap indexPathForNode: in a cache

Convert index paths in delegate methods

Go back on table view

Put collection view back

Switch up the API

Move most ASCollectionView API to ASCollectionNode

Move most table logic over to ASTableNode

Do the things

More conversion work

Keep on keepin' on

Get table view delegate API done

More porting

Simplify

Clear the delegate

More cleanup

Move more stuff around

Remove pointless file

Re-add some API

Put back more API

Use the right flag

* Some cleanup

* Remove incorrect comment

* Tweak the API

* Put back a couple methods

* update example projects (note: ASCollectionView deprecation warnings expected)

* change reloadDataWithCompletion:nil --> reloadData

* Clean up rebase

* Make deprecated numberOfItemsInSection methods optional

* Use the right flag

* Address nits

* update ASDKTube, ASDKgram & ASViewController examples
2016-10-14 17:21:16 -07:00

548 lines
20 KiB
Objective-C

//
// ASTableViewTests.m
// AsyncDisplayKit
//
// 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 "ASTableView.h"
#import "ASTableViewInternal.h"
#import "ASDisplayNode+Subclasses.h"
#import "ASChangeSetDataController.h"
#import "ASCellNode.h"
#import "ASTableNode.h"
#import "ASTableView+Undeprecated.h"
#define NumberOfSections 10
#define NumberOfRowsPerSection 20
#define NumberOfReloadIterations 50
@interface ASTestDataController : ASChangeSetDataController
@property (nonatomic) int numberOfAllNodesRelayouts;
@end
@implementation ASTestDataController
- (void)relayoutAllNodes
{
_numberOfAllNodesRelayouts++;
[super relayoutAllNodes];
}
@end
@interface ASTestTableView : ASTableView
@property (nonatomic, copy) void (^willDeallocBlock)(ASTableView *tableView);
@end
@implementation ASTestTableView
- (instancetype)__initWithFrame:(CGRect)frame style:(UITableViewStyle)style
{
return [super _initWithFrame:frame style:style dataControllerClass:[ASTestDataController class]];
}
- (ASTestDataController *)testDataController
{
return (ASTestDataController *)self.dataController;
}
- (void)dealloc
{
if (_willDeallocBlock) {
_willDeallocBlock(self);
}
}
@end
@interface ASTableViewTestDelegate : NSObject <ASTableDataSource, ASTableDelegate>
@property (nonatomic, copy) void (^willDeallocBlock)(ASTableViewTestDelegate *delegate);
@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;
}
- (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 <ASTableDataSource, ASTableDelegate>
@end
@implementation ASTableViewFilledDataSource
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return NumberOfSections;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return NumberOfRowsPerSection;
}
- (ASCellNode *)tableView:(ASTableView *)tableView nodeForRowAtIndexPath:(NSIndexPath *)indexPath
{
ASTestTextCellNode *textCellNode = [ASTestTextCellNode new];
textCellNode.text = indexPath.description;
return textCellNode;
}
- (ASCellNodeBlock)tableView:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath
{
return ^{
ASTestTextCellNode *textCellNode = [ASTestTextCellNode new];
textCellNode.text = indexPath.description;
return textCellNode;
};
}
@end
@interface ASTableViewFilledDelegate : NSObject <ASTableDelegate>
@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 subsequence 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 reloadDataImmediately];
[tableView setNeedsLayout];
[tableView layoutIfNeeded];
for (int section = 0; section < NumberOfSections; section++) {
for (int row = 0; row < NumberOfRowsPerSection; 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);
}
}
}
// 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
{
NSMutableArray *indexPaths = [NSMutableArray array];
[[self randomIndexSet] enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
NSUInteger rowNum = NumberOfRowsPerSection;
NSIndexPath *sectionIndex = [[NSIndexPath alloc] initWithIndex:idx];
for (NSUInteger i = (existing ? 0 : rowNum); i < (existing ? rowNum : rowNum * 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)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];
//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)testRelayoutAllNodesWithZeroSizeInitially
{
// Initial width of the table view is 0. The first size change is part of the initial config.
// Any subsequence size change after that must trigger a relayout.
CGSize tableViewFinalSize = CGSizeMake(100, 500);
ASTestTableView *tableView = [[ASTestTableView alloc] __initWithFrame:CGRectZero
style:UITableViewStylePlain];
ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new];
tableView.asyncDelegate = dataSource;
tableView.asyncDataSource = dataSource;
// Initial configuration
UIView *superview = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 500, 500)];
[superview addSubview:tableView];
// Width and height are swapped so that a later size change will simulate a rotation
tableView.frame = CGRectMake(0, 0, tableViewFinalSize.height, tableViewFinalSize.width);
[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];
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 < NumberOfRowsPerSection; 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 < NumberOfRowsPerSection; 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:(NumberOfRowsPerSection - 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 < NumberOfRowsPerSection; 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 < NumberOfRowsPerSection; 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);
}
}];
}
- (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 < NumberOfRowsPerSection; 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)]);
}
@end