From 20938c4d9f4f863a6b302aed47afae3bf99dd3fd Mon Sep 17 00:00:00 2001 From: Ben Asher Date: Wed, 5 Sep 2018 16:42:54 -0700 Subject: [PATCH] Add TableView protocol --- ReactiveLists.xcodeproj/project.pbxproj | 4 + Sources/TableViewDriver.swift | 37 +++- Sources/TableViewProtocol.swift | 78 +++++++++ Sources/UITableView+Extensions.swift | 2 +- Tests/TableView/TableViewDiffingTests.swift | 15 +- Tests/TableView/TableViewDriverTests.swift | 51 +++--- Tests/TableView/TableViewMocks.swift | 183 ++++++++++++++++---- Tests/TableView/TestTableViewModels.swift | 30 ++-- 8 files changed, 314 insertions(+), 86 deletions(-) create mode 100644 Sources/TableViewProtocol.swift diff --git a/ReactiveLists.xcodeproj/project.pbxproj b/ReactiveLists.xcodeproj/project.pbxproj index b4adf5a..278a28a 100644 --- a/ReactiveLists.xcodeproj/project.pbxproj +++ b/ReactiveLists.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 357B96E9201956760000443F /* CollectionViewHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 357B96E8201956760000443F /* CollectionViewHeaderView.xib */; }; 357B96EA2019599C0000443F /* CollectionViewHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 357B96E8201956760000443F /* CollectionViewHeaderView.xib */; }; 7C24B1408A8B3A147C254BCA /* Pods_ReactiveLists.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 978763204EC113AFD1F7EB54 /* Pods_ReactiveLists.framework */; }; + A860FAF3214076B000EFEC1B /* TableViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A860FAF2214076B000EFEC1B /* TableViewProtocol.swift */; }; C11B2E5FBD1253C9FFA431A1 /* Pods_ReactiveListsTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 932473A2DAECE0F923C4B570 /* Pods_ReactiveListsTests.framework */; }; /* End PBXBuildFile section */ @@ -135,6 +136,7 @@ 932473A2DAECE0F923C4B570 /* Pods_ReactiveListsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReactiveListsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 950EFA9B77AA7C8F0696C57B /* Pods-ReactiveLists.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactiveLists.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ReactiveLists/Pods-ReactiveLists.debug.xcconfig"; sourceTree = ""; }; 978763204EC113AFD1F7EB54 /* Pods_ReactiveLists.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReactiveLists.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A860FAF2214076B000EFEC1B /* TableViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewProtocol.swift; sourceTree = ""; }; C984EFFC6B6F170CB9AD8DD3 /* Pods-ReactiveListsTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactiveListsTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-ReactiveListsTests/Pods-ReactiveListsTests.release.xcconfig"; sourceTree = ""; }; DFAFA51D8FEF2F413206A2CF /* Pods-ReactiveLists-ReactiveListsExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactiveLists-ReactiveListsExample.release.xcconfig"; path = "Pods/Target Support Files/Pods-ReactiveLists-ReactiveListsExample/Pods-ReactiveLists-ReactiveListsExample.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -269,6 +271,7 @@ 258E31D21F0D8F3100D6F324 /* SupplementaryViewInfo.swift */, 258E31AE1F0D8D9C00D6F324 /* TableViewDriver.swift */, 258E31AF1F0D8D9C00D6F324 /* TableViewModel.swift */, + A860FAF2214076B000EFEC1B /* TableViewProtocol.swift */, 32124A712019312200EE12FC /* Typealiases.swift */, 32753F8C201BB8310084DCB1 /* UICollectionView+Extensions.swift */, 32753F8E201BB8470084DCB1 /* UITableView+Extensions.swift */, @@ -578,6 +581,7 @@ buildActionMask = 2147483647; files = ( 3203532D201BF5FB0024D6CC /* CellContainerViewProtocol.swift in Sources */, + A860FAF3214076B000EFEC1B /* TableViewProtocol.swift in Sources */, 2541B73D1F29A13B002C3090 /* Diffing.swift in Sources */, 258E31B31F0D8D9C00D6F324 /* TableViewDriver.swift in Sources */, 32753F8D201BB8310084DCB1 /* UICollectionView+Extensions.swift in Sources */, diff --git a/Sources/TableViewDriver.swift b/Sources/TableViewDriver.swift index 2a91933..f0286a0 100644 --- a/Sources/TableViewDriver.swift +++ b/Sources/TableViewDriver.swift @@ -35,7 +35,7 @@ open class TableViewDriver: NSObject { } /// The table view to which the `TableViewModel` is rendered. - public let tableView: UITableView + let tableView: TableView private var _tableViewModel: TableViewModel? @@ -72,6 +72,8 @@ open class TableViewDriver: NSObject { private let _automaticDiffingEnabled: Bool + private let _registerViews: (TableViewModel) -> Void + /// Initializes a data source that drives a `UITableView` based on a `TableViewModel`. /// /// - Parameters: @@ -82,15 +84,44 @@ open class TableViewDriver: NSObject { /// - automaticDiffingEnabled: defines whether or not this data source updates the table /// view automatically when cells/sections are moved/inserted/deleted. /// Defaults to `true`. - public init( + public convenience init( tableView: UITableView, tableViewModel: TableViewModel? = nil, shouldDeselectUponSelection: Bool = true, automaticDiffingEnabled: Bool = true) { + self.init( + tableView: tableView, + registerViews: tableView.registerViews(for:), + tableViewModel: tableViewModel, + shouldDeselectUponSelection: shouldDeselectUponSelection, + automaticDiffingEnabled: automaticDiffingEnabled + ) + } + + /// Initializes a data source that drives a `TableView` based on a `TableViewModel`. + /// Externally, this is a `UITableView`. Internally, this can be a mock. + /// + /// - Parameters: + /// - tableView: the table view to which this data source will render its view models. + /// - registerViews: a closure that registers the views for a given `TableViewModel` + /// - tableViewModel: the view model that describes the initial state of this table view. + /// - shouldDeselectUponSelection: indicates if selected cells should immediately be + /// deselected. Defaults to `true`. + /// - automaticDiffingEnabled: defines whether or not this data source updates the table + /// view automatically when cells/sections are moved/inserted/deleted. + /// Defaults to `true`. + init( + tableView: TableView, + registerViews: @escaping (TableViewModel) -> Void, + tableViewModel: TableViewModel? = nil, + shouldDeselectUponSelection: Bool = true, + automaticDiffingEnabled: Bool = true + ) { self._tableViewModel = tableViewModel self.tableView = tableView self._automaticDiffingEnabled = automaticDiffingEnabled self._shouldDeselectUponSelection = shouldDeselectUponSelection + self._registerViews = registerViews super.init() tableView.dataSource = self tableView.delegate = self @@ -163,7 +194,7 @@ open class TableViewDriver: NSObject { } if let newModel = newModel { - self.tableView.registerViews(for: newModel) + self._registerViews(newModel) } let previousStateNilOrEmpty = (oldModel == nil || oldModel!.isEmpty) diff --git a/Sources/TableViewProtocol.swift b/Sources/TableViewProtocol.swift new file mode 100644 index 0000000..26da5c7 --- /dev/null +++ b/Sources/TableViewProtocol.swift @@ -0,0 +1,78 @@ +// +// PlanGrid +// https://www.plangrid.com +// https://medium.com/plangrid-technology +// +// Documentation +// https://plangrid.github.io/ReactiveLists +// +// GitHub +// https://github.com/plangrid/ReactiveLists +// +// License +// Copyright © 2018-present PlanGrid, Inc. +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import DifferenceKit + +/// Protocol that allows ReactiveLists to use a UITableView without knowing about the concrete type +/// This is useful for testing, and it's a step toward having one +protocol TableView: class { + + // MARK: UITableView methods + + var indexPathsForVisibleRows: [IndexPath]? { get } + func beginUpdates() + func endUpdates() + func reloadData() + func cellForRow(at indexPath: IndexPath) -> UITableViewCell? + var dataSource: UITableViewDataSource? { get set } + var delegate: UITableViewDelegate? { get set } + func headerView(forSection section: Int) -> UITableViewHeaderFooterView? + func footerView(forSection section: Int) -> UITableViewHeaderFooterView? + + // MARK: DifferenceKit UITableView extensions + + //swiftlint:disable:next function_parameter_count + func reload( + using stagedChangeset: StagedChangeset, + deleteSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + insertSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + reloadSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + deleteRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + insertRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + reloadRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + interrupt: ((Changeset) -> Bool)?, + setData: (C) -> Void + ) +} + +extension TableView { + + //swiftlint:disable:next function_parameter_count + func reload( + using stagedChangeset: StagedChangeset, + deleteSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + insertSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + reloadSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + deleteRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + insertRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + reloadRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + setData: (C) -> Void + ) { + self.reload( + using: stagedChangeset, + deleteSectionsAnimation: deleteSectionsAnimation, + insertSectionsAnimation: insertRowsAnimation, + reloadSectionsAnimation: reloadSectionsAnimation, + deleteRowsAnimation: deleteRowsAnimation, + insertRowsAnimation: insertRowsAnimation, + reloadRowsAnimation: reloadRowsAnimation, + interrupt: nil, + setData: setData + ) + } +} + +extension UITableView: TableView {} diff --git a/Sources/UITableView+Extensions.swift b/Sources/UITableView+Extensions.swift index 5b74454..4e1bbdc 100644 --- a/Sources/UITableView+Extensions.swift +++ b/Sources/UITableView+Extensions.swift @@ -16,7 +16,7 @@ import UIKit -extension UITableView { +extension TableView where Self: CellContainerViewProtocol, Self.CellType: UITableViewCell { func configuredCell(for model: TableCellViewModel, at indexPath: IndexPath) -> UITableViewCell { let cell = self.dequeueReusableCellFor(identifier: model.registrationInfo.reuseIdentifier, indexPath: indexPath) diff --git a/Tests/TableView/TableViewDiffingTests.swift b/Tests/TableView/TableViewDiffingTests.swift index 39b32c1..eff2034 100644 --- a/Tests/TableView/TableViewDiffingTests.swift +++ b/Tests/TableView/TableViewDiffingTests.swift @@ -14,6 +14,7 @@ // Released under an MIT license: https://opensource.org/licenses/MIT // +import DifferenceKit @testable import ReactiveLists import XCTest @@ -51,8 +52,9 @@ final class TableViewDiffingTests: XCTestCase { self.tableViewDataSource.tableViewModel = updatedModel - XCTAssertEqual(self.mockTableView.callsToInsertRowAtIndexPaths.count, 1) - XCTAssertEqual(self.mockTableView.callsToInsertRowAtIndexPaths[0].indexPaths, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(self.mockTableView.callsToReloadViaDiff.count, 1) + let insertedRows = self.mockTableView.callsToReloadViaDiff[0].elementInserted + XCTAssertEqual(insertedRows, [ElementPath(element: 0, section: 0)]) } func testChangingRowsWithEmptyModles() { @@ -68,7 +70,7 @@ final class TableViewDiffingTests: XCTestCase { self.tableViewDataSource.tableViewModel = updatedModel - XCTAssertEqual(self.mockTableView.callsToInsertRowAtIndexPaths.count, 0) + XCTAssertEqual(self.mockTableView.callsToReloadViaDiff.count, 0) XCTAssertEqual(self.mockTableView.callsToReloadData, 3) } @@ -101,8 +103,9 @@ final class TableViewDiffingTests: XCTestCase { self.tableViewDataSource.tableViewModel = updatedModel - XCTAssertEqual(self.mockTableView.callsToDeleteSections.count, 1) - XCTAssertEqual(self.mockTableView.callsToDeleteSections[0].sections, IndexSet(integer: 0)) + let deletedSections = self.mockTableView.callsToReloadViaDiff[0].sectionDeleted + XCTAssertEqual(deletedSections.count, 1) + XCTAssertEqual(deletedSections, [0]) } func testChangingSectionsThatAreEmpty() { @@ -128,7 +131,7 @@ final class TableViewDiffingTests: XCTestCase { self.tableViewDataSource.tableViewModel = updatedModel - XCTAssertEqual(self.mockTableView.callsToDeleteSections.count, 0) + XCTAssertEqual(self.mockTableView.callsToReloadViaDiff.count, 0) XCTAssertEqual(self.mockTableView.callsToReloadData, 3) } } diff --git a/Tests/TableView/TableViewDriverTests.swift b/Tests/TableView/TableViewDriverTests.swift index 58cd245..b63eebe 100644 --- a/Tests/TableView/TableViewDriverTests.swift +++ b/Tests/TableView/TableViewDriverTests.swift @@ -33,7 +33,7 @@ final class TableViewDriverTests: XCTestCase { /// and a `UITableView`. /// - Parameter tableView: The `UITableView` that is used to present /// the content described in the `TableViewModel`. - private func setupWithTableView(_ tableView: UITableView) { + private func setupWithTableView(_ tableView: TestTableView) { self._tableViewModel = TableViewModel(sectionModels: [ TableSectionViewModel( cellViewModels: [], @@ -56,77 +56,81 @@ final class TableViewDriverTests: XCTestCase { /// Table view sections described in the table view model are converted into views correctly. func testTableViewSections() { - XCTAssertEqual(self._tableViewDataSource.sectionIndexTitles(for: self._tableView)!, ["A", "Z", "Z"]) + let testTableView = self._tableViewDataSource.testTableView + XCTAssertEqual(self._tableViewDataSource.sectionIndexTitles(for: testTableView)!, ["A", "Z", "Z"]) - XCTAssertEqual(self._tableViewDataSource.numberOfSections(in: self._tableView), 3) + XCTAssertEqual(self._tableViewDataSource.numberOfSections(in: testTableView), 3) parameterize(cases: (0, 10), (1, CGFloat.leastNormalMagnitude), (2, 30), (9, CGFloat.leastNormalMagnitude)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, heightForHeaderInSection: $0), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, heightForHeaderInSection: $0), $1) } parameterize(cases: (0, 11), (1, 21), (2, CGFloat.leastNormalMagnitude), (9, CGFloat.leastNormalMagnitude)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, heightForFooterInSection: $0), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, heightForFooterInSection: $0), $1) } parameterize(cases: (0, nil), (1, nil), (2, "header_3"), (9, nil)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, titleForHeaderInSection: $0), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, titleForHeaderInSection: $0), $1) } parameterize(cases: (0, nil), (1, "footer_2"), (2, nil), (9, nil)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, titleForFooterInSection: $0), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, titleForFooterInSection: $0), $1) } parameterize(cases: (0, 0), (1, 3), (2, 3), (9, 0)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, numberOfRowsInSection: $0), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, numberOfRowsInSection: $0), $1) } } /// Table view rows described in the table view model are converted into views correctly. func testTableViewRows() { + let testTableView = self._tableViewDataSource.testTableView parameterize(cases: (0, 44), (1, 42), (2, 42), (9, 44)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, heightForRowAt: path($0)), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, heightForRowAt: path($0)), $1) } parameterize(cases: (0, UITableViewCellEditingStyle.none), (1, .delete), (2, .delete), (9, .none)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, editingStyleForRowAt: path($0)), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, editingStyleForRowAt: path($0)), $1) } parameterize(cases: (0, true), (1, false), (2, false), (9, true)) { - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, shouldHighlightRowAt: path($0)), $1) - XCTAssertEqual(self._tableViewDataSource.tableView(self._tableView, shouldIndentWhileEditingRowAt: path($0)), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, shouldHighlightRowAt: path($0)), $1) + XCTAssertEqual(self._tableViewDataSource.tableView(testTableView, shouldIndentWhileEditingRowAt: path($0)), $1) } } /// Table view section headers described in the table view model are converted into views correctly. func testExistingSectionHeaders() { + let testTableView = self._tableViewDataSource.testTableView let section = 0 let indexKey = path(section) - let header = self._tableViewDataSource._getHeader(section) + let header: HeaderView? = self._tableViewDataSource._getHeader(section) XCTAssertEqual(header?.label, "title_header+A") XCTAssertEqual(header?.accessibilityIdentifier, "access_header+0") - guard let onScreenHeader = self._tableViewDataSource.tableView(self._tableView, viewForHeaderInSection: indexKey.section) as? TestTableViewSectionHeaderFooter else { - XCTFail("Did not find the on screen TestTableViewSectionHeaderFooter header") + guard let onScreenHeader = self._tableViewDataSource.tableView(testTableView, viewForHeaderInSection: indexKey.section) as? HeaderView else { + XCTFail("Did not find the on screen HeaderView header") return } XCTAssertEqual(onScreenHeader.label, "title_header+A") - XCTAssertNil(self._tableView.headerView(forSection: indexKey.section)) + XCTAssertNotNil(self._tableView.headerView(forSection: indexKey.section)) } /// Table view section footers described in the table view model are converted into views correctly. func testExistingSectionFooters() { + let testTableView = self._tableViewDataSource.testTableView let section = 0 let indexKey = path(section) - let footer = self._tableViewDataSource._getFooter(section) + let footer: FooterView? = self._tableViewDataSource._getFooter(section) XCTAssertEqual(footer?.label, "title_footer+A") XCTAssertEqual(footer?.accessibilityIdentifier, "access_footer+0") - guard let onScreenFooter = self._tableViewDataSource.tableView(self._tableView, viewForFooterInSection: indexKey.section) as? TestTableViewSectionHeaderFooter else { - XCTFail("Did not find the on screen TestTableViewSectionHeaderFooter footer") + guard let onScreenFooter = self._tableViewDataSource.tableView(testTableView, viewForFooterInSection: indexKey.section) as? FooterView else { + XCTFail("Did not find the on screen FooterView footer") return } XCTAssertEqual(onScreenFooter.label, "title_footer+A") - XCTAssertNil(self._tableView.footerView(forSection: indexKey.section)) + XCTAssertNotNil(self._tableView.footerView(forSection: indexKey.section)) } /// Table view cells described in the table view model are converted into views correctly. @@ -186,7 +190,7 @@ final class TableViewDriverTests: XCTestCase { /// Selected cells are automatically deselected by default. func testShouldDeselectUponSelection() { - let tableView = TestTableView() + let tableView = SelectionTestTableView() let dataSource = TableViewDriver(tableView: tableView) XCTAssertEqual(tableView.callsToDeselect, 0) dataSource.tableView(tableView, didSelectRowAt: path(0)) @@ -196,7 +200,7 @@ final class TableViewDriverTests: XCTestCase { /// When the option is disabled, selected cells are no longer /// immediately deselected. func testShouldNotDeselectUponSelection() { - let tableView = TestTableView() + let tableView = SelectionTestTableView() let dataSource = TableViewDriver( tableView: tableView, shouldDeselectUponSelection: false @@ -213,7 +217,8 @@ final class TableViewDriverTests: XCTestCase { let tableView = TestTableView() self.setupWithTableView(tableView) - XCTAssertEqual(tableView.callsToRegisterClass.count, 2) + // 1 header + 1 footer + 6 cells + XCTAssertEqual(tableView.callsToRegisterClass.count, 8) XCTAssertEqual(tableView.callsToRegisterClass[0].identifier, "HeaderView") XCTAssertEqual(tableView.callsToRegisterClass[1].identifier, "FooterView") XCTAssert(tableView.callsToRegisterClass[0].viewClass === HeaderView.self) diff --git a/Tests/TableView/TableViewMocks.swift b/Tests/TableView/TableViewMocks.swift index afdb3e3..7bd3824 100644 --- a/Tests/TableView/TableViewMocks.swift +++ b/Tests/TableView/TableViewMocks.swift @@ -14,74 +14,175 @@ // Released under an MIT license: https://opensource.org/licenses/MIT // +import DifferenceKit @testable import ReactiveLists -class HeaderView: UITableViewHeaderFooterView {} -class FooterView: UITableViewHeaderFooterView {} +final class HeaderView: UITableViewHeaderFooterView { + var identifier: String? + var label: String? -class TestTableView: UITableView { + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + self.identifier = reuseIdentifier + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class FooterView: UITableViewHeaderFooterView { + var identifier: String? + var label: String? + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + self.identifier = reuseIdentifier + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class TestTableView { var callsToRegisterClass: [(viewClass: AnyClass?, identifier: String)] = [] - var callsToDeselect = 0 - var callsToInsertRowAtIndexPaths: [(indexPaths: [IndexPath], animation: UITableViewRowAnimation)] = [] - var callsToDeleteSections: [(sections: IndexSet, animation: UITableViewRowAnimation)] = [] var callsToReloadData = 0 + var callsToReloadViaDiff: [Changeset<[TableSectionViewModel]>] = [] - override var indexPathsForVisibleRows: [IndexPath]? { - return (0.. [IndexPath] in - (0.. UITableViewCell? { - return self.dataSource?.tableView(self, cellForRowAt: indexPath) + func headerView(forSection section: Int) -> UITableViewHeaderFooterView? { + return self.delegate?.tableView?(self.testTableView, viewForHeaderInSection: section) as? UITableViewHeaderFooterView } - override func dequeueReusableCell(withIdentifier identifier: String, for indexPath: IndexPath) -> UITableViewCell { - return TestTableViewCell(identifier: identifier) + func footerView(forSection section: Int) -> UITableViewHeaderFooterView? { + return self.delegate?.tableView?(self.testTableView, viewForFooterInSection: section) as? UITableViewHeaderFooterView } - override func dequeueReusableHeaderFooterView(withIdentifier identifier: String) -> UITableViewHeaderFooterView? { - return TestTableViewSectionHeaderFooter(identifier: identifier) + func beginUpdates() {} + func endUpdates() {} + + var indexPathsForVisibleRows: [IndexPath]? { + let numberOfSections = self.dataSource?.numberOfSections?(in: self.testTableView) ?? 0 + return (0.. [IndexPath] in + let numberOfRows = self.dataSource?.tableView(self.testTableView, numberOfRowsInSection: section) ?? 0 + return (0.. UITableViewCell? { + return self.dataSource?.tableView(self.testTableView, cellForRowAt: indexPath) } - override func deselectRow(at indexPath: IndexPath, animated: Bool) { - self.callsToDeselect += 1 + func reloadData() { + self.callsToReloadData += 1 + } + + //swiftlint:disable:next function_parameter_count + func reload( + using stagedChangeset: StagedChangeset, + deleteSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + insertSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + reloadSectionsAnimation: @autoclosure () -> UITableViewRowAnimation, + deleteRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + insertRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + reloadRowsAnimation: @autoclosure () -> UITableViewRowAnimation, + interrupt: ((Changeset) -> Bool)?, + setData: (C) -> Void + ) where C: Collection { + if let stagedChangeset = stagedChangeset as? StagedChangeset<[TableSectionViewModel]> { + var fullChangeset = Changeset<[TableSectionViewModel]>(data: []) + + // combine the staged changesets into one for easy inspection in tests + for changeset in stagedChangeset { + fullChangeset.data += changeset.data + fullChangeset.sectionDeleted += changeset.sectionDeleted + fullChangeset.sectionInserted += changeset.sectionInserted + fullChangeset.sectionUpdated += changeset.sectionUpdated + fullChangeset.sectionMoved += changeset.sectionMoved + fullChangeset.elementDeleted += changeset.elementDeleted + fullChangeset.elementInserted += changeset.elementInserted + fullChangeset.elementUpdated += changeset.elementUpdated + fullChangeset.elementMoved += changeset.elementMoved + } + + self.callsToReloadViaDiff.append(fullChangeset) + } } +} + +extension TestTableView: CellContainerViewProtocol { - override func insertRows(at indexPaths: [IndexPath], with animation: UITableViewRowAnimation) { - super.insertRows(at: indexPaths, with: animation) - self.callsToInsertRowAtIndexPaths.append((indexPaths: indexPaths, animation: animation)) + typealias CellType = TestTableViewCell + typealias SupplementaryType = UITableViewHeaderFooterView + + func dequeueReusableCellFor(identifier: String, indexPath: IndexPath) -> TestTableViewCell { + return TestTableViewCell(identifier: identifier) } - override func deleteSections(_ sections: IndexSet, with animation: UITableViewRowAnimation) { - super.deleteSections(sections, with: animation) - self.callsToDeleteSections.append((sections: sections, animation: animation)) + func dequeueReusableSupplementaryViewFor(kind: SupplementaryViewKind, identifier: String, indexPath: IndexPath) -> UITableViewHeaderFooterView? { + return nil } - override func reloadData() { - super.reloadData() - self.callsToReloadData += 1 + func registerCellClass(_ cellClass: AnyClass?, identifier: String) { + self.callsToRegisterClass.append((viewClass: cellClass, identifier: identifier)) } + + func registerCellNib(_ cellNib: UINib?, identifier: String) {} + + func registerSupplementaryClass(_ supplementaryClass: AnyClass?, kind: SupplementaryViewKind, identifier: String) { + self.callsToRegisterClass.append((viewClass: supplementaryClass, identifier: identifier)) + } + + func registerSupplementaryNib(_ supplementaryNib: UINib?, kind: SupplementaryViewKind, identifier: String) {} } extension TableViewDriver { func _getCell(_ path: IndexPath) -> TestTableViewCell? { - guard let cell = self.tableView(self.tableView, cellForRowAt: path) as? TestTableViewCell else { return nil } - return cell + return self.tableView.dataSource?.tableView(self.testTableView, cellForRowAt: path) as? TestTableViewCell } - func _getHeader(_ section: Int) -> TestTableViewSectionHeaderFooter? { - guard let cell = self.tableView(self.tableView, viewForHeaderInSection: section) as? TestTableViewSectionHeaderFooter else { return nil } - return cell + func _getHeader(_ section: Int) -> HeaderView? { + return self.tableView.delegate?.tableView?(self.testTableView, viewForHeaderInSection: section) as? + HeaderView } - func _getFooter(_ section: Int) -> TestTableViewSectionHeaderFooter? { - guard let cell = self.tableView(self.tableView, viewForFooterInSection: section) as? TestTableViewSectionHeaderFooter else { return nil } - return cell + func _getFooter(_ section: Int) -> FooterView? { + return self.tableView.delegate?.tableView?(self.testTableView, viewForFooterInSection: section) as? FooterView + } +} + +extension TableViewDriver { + convenience init( + tableView: TestTableView, + tableViewModel: TableViewModel? = nil, + shouldDeselectUponSelection: Bool = true, + automaticDiffingEnabled: Bool = true + ) { + self.init( + tableView: tableView, + registerViews: tableView.registerViews(for:), + tableViewModel: tableViewModel, + shouldDeselectUponSelection: shouldDeselectUponSelection, + automaticDiffingEnabled: automaticDiffingEnabled + ) + } + + var testTableView: UITableView { + let tableView = UITableView() + if let tableViewModel = self.tableViewModel { + tableView.registerViews(for: tableViewModel) + } + return tableView } } @@ -106,3 +207,11 @@ class MockCellViewModel: TableCellViewModel { self.commitEditingStyle = { [unowned self] in self.commitEditingStyleCalled = $0 } } } + +final class SelectionTestTableView: UITableView { + var callsToDeselect = 0 + + public override func deselectRow(at indexPath: IndexPath, animated: Bool) { + self.callsToDeselect += 1 + } +} diff --git a/Tests/TableView/TestTableViewModels.swift b/Tests/TableView/TestTableViewModels.swift index 149fbbb..a65be72 100644 --- a/Tests/TableView/TestTableViewModels.swift +++ b/Tests/TableView/TestTableViewModels.swift @@ -58,6 +58,10 @@ class TestTableViewCell: UITableViewCell { var identifier: String? var label: String? + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + init(identifier: String) { self.identifier = identifier super.init(style: .default, reuseIdentifier: identifier) @@ -108,21 +112,15 @@ struct TestHeaderFooterViewModel: TableSectionHeaderFooterViewModel { } func applyViewModelToView(_ view: UIView) { - guard let view = view as? TestTableViewSectionHeaderFooter else { return } - view.label = self.title - } -} - -class TestTableViewSectionHeaderFooter: UITableViewHeaderFooterView { - var identifier: String? - var label: String? - - init(identifier: String) { - self.identifier = identifier - super.init(reuseIdentifier: identifier) - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) + switch self.viewInfo!.kind { + case .header: + if let view = view as? HeaderView { + view.label = self.title + } + case .footer: + if let view = view as? FooterView { + view.label = self.title + } + } } }