diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme index 194c762..96ed47b 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposedUI.xcscheme @@ -26,8 +26,19 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + + + + ).forEach { updateDelegate?.section(self, didRemoveElementAt: $0) } updateDelegate?.didEndUpdating(self) @@ -159,7 +159,7 @@ extension ArraySection: MutableCollection, RandomAccessCollection, Bidirectional public func removeAll() { updateDelegate?.willBeginUpdating(self) let indexes = IndexSet(integersIn: indices) - indexes.forEach { updateDelegate?.section(self, didRemoveElementAt: $0) } + indexes.sorted(by: >).forEach { updateDelegate?.section(self, didRemoveElementAt: $0) } elements.removeAll() updateDelegate?.didEndUpdating(self) } diff --git a/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift b/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift index a04ab4f..db76d9d 100644 --- a/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift +++ b/Sources/ComposedUI/CollectionView/CollectionCoordinator.swift @@ -1,5 +1,6 @@ import UIKit import Composed +import os.log /// Conform to this protocol to receive `CollectionCoordinator` events public protocol CollectionCoordinatorDelegate: class { @@ -38,17 +39,16 @@ open class CollectionCoordinator: NSObject { return mapper.provider } - private var mapper: SectionProviderMapping + /// If `true` this `CollectionCoordinator` instance will log changes to the system log. + public var enableLogs: Bool = false + + internal var changesReducer = ChangesReducer() - private var defersUpdate: Bool = false - private var sectionRemoves: [() -> Void] = [] - private var sectionInserts: [() -> Void] = [] - private var sectionUpdates: [() -> Void] = [] + private var mapper: SectionProviderMapping - private var removes: [() -> Void] = [] - private var inserts: [() -> Void] = [] - private var changes: [() -> Void] = [] - private var moves: [() -> Void] = [] + private var isPerformingBatchedUpdates: Bool { + changesReducer.hasActiveUpdates + } private let collectionView: UICollectionView @@ -159,6 +159,8 @@ open class CollectionCoordinator: NSObject { // Prepares and caches the section to improve performance private func prepareSections() { + debugLog("Preparing sections") + cachedProviders.removeAll() mapper.delegate = self @@ -169,20 +171,8 @@ open class CollectionCoordinator: NSObject { switch section.cell.dequeueMethod { case let .fromNib(type): - // `UINib(nibName:bundle:)` is an expensive call because it reads the NIB from the - // disk, which can have a large impact on performance when this is called multiple times. - // - // Each registration is cached to ensure that the same nib is not read from disk multiple times. - - let nibName = String(describing: type) - let nibBundle = Bundle(for: type) - let nibRegistration = NIBRegistration(nibName: nibName, bundle: nibBundle, reuseIdentifier: section.cell.reuseIdentifier) - - guard !nibRegistrations.contains(nibRegistration) else { break } - - let nib = UINib(nibName: nibName, bundle: nibBundle) + let nib = UINib(nibName: String(describing: type), bundle: Bundle(for: type)) collectionView.register(nib, forCellWithReuseIdentifier: section.cell.reuseIdentifier) - nibRegistrations.insert(nibRegistration) case let .fromClass(type): collectionView.register(type, forCellWithReuseIdentifier: section.cell.reuseIdentifier) case .fromStoryboard: @@ -210,31 +200,30 @@ open class CollectionCoordinator: NSObject { delegate?.coordinatorDidUpdate(self) } + fileprivate func debugLog(_ message: String) { + if #available(iOS 12, *), enableLogs { + os_log("%@", log: OSLog(subsystem: "ComposedUI", category: "CollectionCoordinator"), type: .debug, message) + } + } } // MARK: - SectionProviderMappingDelegate extension CollectionCoordinator: SectionProviderMappingDelegate { - - private func reset() { - removes.removeAll() - inserts.removeAll() - changes.removeAll() - moves.removeAll() - sectionInserts.removeAll() - sectionRemoves.removeAll() - } - public func mappingDidInvalidate(_ mapping: SectionProviderMapping) { assert(Thread.isMainThread) - reset() + + debugLog(#function) + changesReducer = ChangesReducer() prepareSections() collectionView.reloadData() } public func mappingWillBeginUpdating(_ mapping: SectionProviderMapping) { - reset() - defersUpdate = true + debugLog(#function) + assert(Thread.isMainThread) + + changesReducer.beginUpdating() // This is called here to ensure that the collection view's internal state is in-sync with the state of the // data in hierarchy of sections. If this is not done it can cause various crashes when `performBatchUpdates` is called @@ -246,89 +235,105 @@ extension CollectionCoordinator: SectionProviderMappingDelegate { } public func mappingDidEndUpdating(_ mapping: SectionProviderMapping) { + debugLog(#function) assert(Thread.isMainThread) + + guard let changeset = changesReducer.endUpdating() else { return } + + /** + _Item_ deletes are processed first, with indexes relative to the state at the start of `performBatchUpdates`. + + _Section_ are processed next, with indexes relative to the state at the start of `performBatchUpdates` (since section indexes are not changed by item deletes). + + All other updates are processed relative to the indexes **after** these deletes have occurred. + */ + debugLog("Performing batch updates") collectionView.performBatchUpdates({ - if defersUpdate { - prepareSections() + prepareSections() + + debugLog("Deleting sections \(changeset.groupsRemoved.sorted(by: >))") + collectionView.deleteSections(IndexSet(changeset.groupsRemoved)) + + debugLog("Deleting items \(changeset.elementsRemoved.sorted(by: >))") + collectionView.deleteItems(at: Array(changeset.elementsRemoved)) + + debugLog("Inserting items \(changeset.elementsInserted.sorted(by: <))") + collectionView.insertItems(at: Array(changeset.elementsInserted)) + + debugLog("Reloading items \(changeset.elementsUpdated.sorted(by: <))") + collectionView.reloadItems(at: Array(changeset.elementsUpdated)) + + changeset.elementsMoved.forEach { move in + debugLog("Moving \(move.from) to \(move.to)") + collectionView.moveItem(at: move.from, to: move.to) } - removes.forEach { $0() } - inserts.forEach { $0() } - changes.forEach { $0() } - moves.forEach { $0() } - sectionRemoves.forEach { $0() } - sectionInserts.forEach { $0() } - sectionUpdates.forEach { $0() } - reset() - defersUpdate = false + debugLog("Inserting sections \(changeset.groupsInserted.sorted(by: >))") + collectionView.insertSections(IndexSet(changeset.groupsInserted)) }) } - public func mapping(_ mapping: SectionProviderMapping, didUpdateSections sections: IndexSet) { - assert(Thread.isMainThread) - sectionUpdates.append { [weak self] in - guard let self = self else { return } - if !self.defersUpdate { self.prepareSections() } - self.collectionView.reloadSections(sections) - } - if defersUpdate { return } - mappingDidEndUpdating(mapping) - } - public func mapping(_ mapping: SectionProviderMapping, didInsertSections sections: IndexSet) { assert(Thread.isMainThread) - sectionInserts.append { [weak self] in - guard let self = self else { return } - if !self.defersUpdate { self.prepareSections() } - self.collectionView.insertSections(sections) + + guard isPerformingBatchedUpdates else { + prepareSections() + collectionView.insertSections(sections) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.insertGroups(sections) } public func mapping(_ mapping: SectionProviderMapping, didRemoveSections sections: IndexSet) { assert(Thread.isMainThread) - sectionRemoves.append { [weak self] in - guard let self = self else { return } - if !self.defersUpdate { self.prepareSections() } - self.collectionView.deleteSections(sections) + + guard isPerformingBatchedUpdates else { + prepareSections() + collectionView.deleteSections(sections) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.removeGroups(sections) } public func mapping(_ mapping: SectionProviderMapping, didInsertElementsAt indexPaths: [IndexPath]) { assert(Thread.isMainThread) - inserts.append { [weak self] in - guard let self = self else { return } - self.collectionView.insertItems(at: indexPaths) + + guard isPerformingBatchedUpdates else { + prepareSections() + collectionView.insertItems(at: indexPaths) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.insertElements(at: indexPaths) } public func mapping(_ mapping: SectionProviderMapping, didRemoveElementsAt indexPaths: [IndexPath]) { assert(Thread.isMainThread) - removes.append { [weak self] in - guard let self = self else { return } - self.collectionView.deleteItems(at: indexPaths) + + guard isPerformingBatchedUpdates else { + prepareSections() + collectionView.deleteItems(at: indexPaths) + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.removeElements(at: indexPaths) } public func mapping(_ mapping: SectionProviderMapping, didUpdateElementsAt indexPaths: [IndexPath]) { assert(Thread.isMainThread) - changes.append { [weak self] in - guard let self = self else { return } + + guard isPerformingBatchedUpdates else { + prepareSections() var indexPathsToReload: [IndexPath] = [] for indexPath in indexPaths { guard let section = self.sectionProvider.sections[indexPath.section] as? CollectionUpdateHandler, - !section.prefersReload(forElementAt: indexPath.item), - let cell = self.collectionView.cellForItem(at: indexPath) else { - indexPathsToReload.append(indexPath) - continue + !section.prefersReload(forElementAt: indexPath.item), + let cell = self.collectionView.cellForItem(at: indexPath) else { + indexPathsToReload.append(indexPath) + continue } self.cachedProviders[indexPath.section].cell.configure(cell, indexPath.item, self.mapper.provider.sections[indexPath.section]) @@ -341,19 +346,22 @@ extension CollectionCoordinator: SectionProviderMappingDelegate { self.collectionView.reloadItems(at: indexPathsToReload) CATransaction.setDisableActions(false) CATransaction.commit() + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.updateElements(at: indexPaths) } public func mapping(_ mapping: SectionProviderMapping, didMoveElementsAt moves: [(IndexPath, IndexPath)]) { assert(Thread.isMainThread) - self.moves.append { [weak self] in - guard let self = self else { return } - moves.forEach { self.collectionView.moveItem(at: $0.0, to: $0.1) } + + guard isPerformingBatchedUpdates else { + prepareSections() + moves.forEach { collectionView.moveItem(at: $0.0, to: $0.1) } + return } - if defersUpdate { return } - mappingDidEndUpdating(mapping) + + changesReducer.moveElements(moves) } public func mapping(_ mapping: SectionProviderMapping, selectedIndexesIn section: Int) -> [Int] { @@ -373,7 +381,8 @@ extension CollectionCoordinator: SectionProviderMappingDelegate { } public func mapping(_ mapping: SectionProviderMapping, move sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath) + // TODO: Check `isPerformingBatchedUpdates` + self.mapping(mapping, didMoveElementsAt: [(sourceIndexPath, destinationIndexPath)]) } } @@ -465,7 +474,7 @@ extension CollectionCoordinator: UICollectionViewDataSource { } else { guard let view = originalDataSource?.collectionView?(collectionView, viewForSupplementaryElementOfKind: kind, at: indexPath) else { // when in production its better to return 'something' to prevent crashing - assertionFailure("Unsupported supplementary kind: \(kind) at indexPath: \(indexPath). Did you forget to register your header or footer?") + assertionFailure("Unsupported supplementary kind: \(kind) at indexPath: \(indexPath). Check if your layout it returning attributes for the supplementary element at \(indexPath)") return collectionView.dequeue(supplementary: PlaceholderSupplementaryView.self, ofKind: PlaceholderSupplementaryView.kind, for: indexPath) } @@ -508,8 +517,8 @@ extension CollectionCoordinator { public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler, - provider.allowsContextMenu(forElementAt: indexPath.item), - let cell = collectionView.cellForItem(at: indexPath) else { return nil } + provider.allowsContextMenu(forElementAt: indexPath.item), + let cell = collectionView.cellForItem(at: indexPath) else { return nil } let preview = provider.contextMenu(previewForElementAt: indexPath.item, cell: cell) return UIContextMenuConfiguration(identifier: indexPath.string, previewProvider: preview) { suggestedElements in return provider.contextMenu(forElementAt: indexPath.item, cell: cell, suggestedActions: suggestedElements) @@ -519,21 +528,21 @@ extension CollectionCoordinator { public func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return nil } guard let cell = collectionView.cellForItem(at: indexPath), - let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } + let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } return provider.contextMenu(previewForHighlightingElementAt: indexPath.item, cell: cell) } public func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return nil } guard let cell = collectionView.cellForItem(at: indexPath), - let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } + let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return nil } return provider.contextMenu(previewForDismissingElementAt: indexPath.item, cell: cell) } public func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { guard let identifier = configuration.identifier as? String, let indexPath = IndexPath(string: identifier) else { return } guard let cell = collectionView.cellForItem(at: indexPath), - let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return } + let provider = mapper.provider.sections[indexPath.section] as? CollectionContextMenuHandler else { return } provider.contextMenu(willPerformPreviewActionForElementAt: indexPath.item, cell: cell, animator: animator) } @@ -703,8 +712,8 @@ extension CollectionCoordinator: UICollectionViewDropDelegate { guard !indexPath.isEmpty else { return nil } guard let section = sectionProvider.sections[indexPath.section] as? CollectionDragHandler, - let cell = collectionView.cellForItem(at: indexPath) else { - return originalDragDelegate?.collectionView?(collectionView, dragPreviewParametersForItemAt: indexPath) + let cell = collectionView.cellForItem(at: indexPath) else { + return originalDragDelegate?.collectionView?(collectionView, dragPreviewParametersForItemAt: indexPath) } return section.dragSession(previewParametersForElementAt: indexPath.item, cell: cell) @@ -712,7 +721,7 @@ extension CollectionCoordinator: UICollectionViewDropDelegate { public func collectionView(_ collectionView: UICollectionView, dropPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? { guard let section = sectionProvider.sections[indexPath.section] as? CollectionDropHandler, - let cell = collectionView.cellForItem(at: indexPath) else { + let cell = collectionView.cellForItem(at: indexPath) else { return originalDropDelegate? .collectionView?(collectionView, dropPreviewParametersForItemAt: indexPath) } @@ -750,8 +759,8 @@ extension CollectionCoordinator: UICollectionViewDropDelegate { let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0) guard coordinator.proposal.operation == .move, - let section = sectionProvider.sections[destinationIndexPath.section] as? MoveHandler else { - return + let section = sectionProvider.sections[destinationIndexPath.section] as? MoveHandler else { + return } let item = coordinator.items.lazy diff --git a/Sources/ComposedUI/Common/ChangesReducer.swift b/Sources/ComposedUI/Common/ChangesReducer.swift new file mode 100644 index 0000000..1cb63a3 --- /dev/null +++ b/Sources/ComposedUI/Common/ChangesReducer.swift @@ -0,0 +1,295 @@ +import Foundation + +/** + A value that collects and reduces changes to allow them to allow multiple changes + to be applied at once. + + The logic of how to reduce the changes is designed to match that of `UICollectionView` + and `UITableView`, allowing for reuse between both. + + `ChangesReducer` uses the generalised terms "group" and "element", which can be mapped directly + to "section" and "row" for `UITableView`s and "section" and "item" for `UICollectionView`. + + Final updates are applied in the order: + + | Update | Order | Indexes | + |------------------|-------------|----------| + | Element Removals | High to low | Original | + | Element Reloads | N/A | Original | + | Group removals | High to low | Original | + + https://developer.apple.com/videos/play/wwdc2018/225/ is useful. Page 62 of the slides helps confirm the above table. + + - Element removals + - Using original index paths + - Group removals + - Using original index paths + - Element moves + - Decomposed in to delete and insert + - Delete post-element removals, but pre-group removals? + + To confirm: + - Group inserts + - Using index paths after removals + - Element inserts + - Using index paths after removals + - Group reloads + - Using index paths after removals and inserts + - Element reloads + - Using index paths after removals and inserts + */ +internal struct ChangesReducer { + internal var hasActiveUpdates: Bool { + return activeUpdates > 0 + } + + private var activeUpdates = 0 + + private var changeset: Changeset = Changeset() + + /// Clears existing updates, keeping active updates count. + internal mutating func clearUpdates() { + changeset = Changeset() + } + + /// Begin performing updates. This must be called prior to making updates. + /// + /// It is possible to call this function multiple times to build up a batch of changes. + /// + /// All calls to this must be balanced with a call to `endUpdating`. + internal mutating func beginUpdating() { + activeUpdates += 1 + } + + /// End the current collection of updates. + /// + /// - Returns: The completed changeset, if this ends the last update in the batch. + internal mutating func endUpdating() -> Changeset? { + activeUpdates -= 1 + + guard activeUpdates == 0 else { + assert(activeUpdates > 0, "`endUpdating` calls must be balanced with `beginUpdating`") + return nil + } + + let changeset = self.changeset + self.changeset = Changeset() + return changeset + } + + internal mutating func insertGroups(_ groups: IndexSet) { + groups.forEach { insertedGroup in + changeset.groupsInserted = Set(changeset.groupsInserted.map { existingInsertedGroup in + if existingInsertedGroup >= insertedGroup { + return existingInsertedGroup + 1 + } + + return existingInsertedGroup + }) + + if changeset.groupsRemoved.contains(insertedGroup) { + changeset.groupsInserted.insert(insertedGroup) + } else { + changeset.groupsInserted.insert(insertedGroup) + } + + changeset.elementsInserted = Set(changeset.elementsInserted.map { insertedIndexPath in + var insertedIndexPath = insertedIndexPath + + if insertedIndexPath.section >= insertedGroup { + insertedIndexPath.section += 1 + } + + return insertedIndexPath + }) + + changeset.elementsMoved = Set(changeset.elementsMoved.map { move in + var move = move + + if move.from.section > insertedGroup { + move.from.section += 1 + } + + if move.to.section > insertedGroup { + move.to.section += 1 + } + + return move + }) + } + } + + internal mutating func removeGroups(_ groups: [Int]) { + removeGroups(IndexSet(groups)) + } + + internal mutating func removeGroups(_ groups: IndexSet) { + groups.sorted(by: >).forEach { removedGroup in + var removedGroup = removedGroup + + if changeset.groupsInserted.remove(removedGroup) != nil { + changeset.groupsInserted = Set(changeset.groupsInserted.map { insertedGroup in + if insertedGroup > removedGroup { + return insertedGroup - 1 + } + + return insertedGroup + }) + removedGroup = transformSection(removedGroup) + } else { + changeset.groupsInserted = Set(changeset.groupsInserted.map { insertedGroup in + if insertedGroup > removedGroup { + return insertedGroup - 1 + } + + return insertedGroup + }) + removedGroup = transformSection(removedGroup) + changeset.groupsRemoved.insert(removedGroup) + } + + changeset.elementsUpdated = Set(changeset.elementsUpdated.filter { $0.section != removedGroup }) + + changeset.elementsInserted = Set(changeset.elementsInserted.compactMap { insertedIndexPath in + guard insertedIndexPath.section != removedGroup else { return nil } + + var batchedRowInsert = insertedIndexPath + + if batchedRowInsert.section > removedGroup { + batchedRowInsert.section -= 1 + } + + return batchedRowInsert + }) + + changeset.elementsMoved = Set(changeset.elementsMoved.compactMap { move in + guard move.to.section != removedGroup else { return nil } + + var move = move + + if move.from.section > removedGroup { + move.from.section -= 1 + } + + if move.to.section > removedGroup { + move.to.section -= 1 + } + + return move + }) + } + } + + internal mutating func insertElements(at indexPaths: [IndexPath]) { + indexPaths.forEach { insertedIndexPath in + changeset.elementsInserted.insert(insertedIndexPath) + } + } + + internal mutating func removeElements(at indexPaths: [IndexPath]) { + /** + Element removals are handled before all other updates. + */ + indexPaths.sorted(by: { $0.item > $1.item }).forEach { removedIndexPath in + var removedIndexPath = transformIndexPath(removedIndexPath, toContext: .original) + + if !changeset.groupsInserted.contains(removedIndexPath.section) { + let itemInsertsInSection = changeset + .elementsInserted + .filter { $0.section == removedIndexPath.section } + .map(\.item) + + if changeset.elementsRemoved.contains(removedIndexPath), changeset.elementsInserted.remove(removedIndexPath) != nil { + return + } + + changeset.elementsInserted = Set(changeset.elementsInserted.map { existingInsertedIndexPath in + guard existingInsertedIndexPath.section == removedIndexPath.section else { + // Different section; don't modify + return existingInsertedIndexPath + } + + guard !changeset.elementsRemoved.contains(existingInsertedIndexPath) else { + // This insert is really a reload (delete and insert) + return existingInsertedIndexPath + } + + var existingInsertedIndexPath = existingInsertedIndexPath + + if existingInsertedIndexPath.item > removedIndexPath.item { + existingInsertedIndexPath.item -= 1 + } else if existingInsertedIndexPath.item == removedIndexPath.item && !changeset.elementsRemoved.contains(existingInsertedIndexPath) { + existingInsertedIndexPath.item -= 1 + } + + return existingInsertedIndexPath + }) + + let itemRemovalsInSection = changeset + .elementsRemoved + .filter { $0.section == removedIndexPath.section } + .map(\.item) + + let availableSpaces = (0.. $1.item }).forEach { updatedElement in + let updatedElement = transformIndexPath(updatedElement, toContext: .original) + + if !changeset.groupsInserted.contains(updatedElement.section) { + changeset.elementsUpdated.insert(updatedElement) + } + } + } + + internal mutating func moveElements(_ moves: [Changeset.Move]) { + changeset.elementsMoved.formUnion(moves) + } + + internal mutating func moveElements(_ moves: [(from: IndexPath, to: IndexPath)]) { + moveElements(moves.map { Changeset.Move(from: $0.from, to: $0.to) }) + } + + private enum IndexPathContext { + /// Start of updates. + case original + + /// After deletes and reloads + case afterUpdates + } + + private func transformIndexPath(_ indexPath: IndexPath, toContext context: IndexPathContext) -> IndexPath { + var indexPath = indexPath + + switch context { + case .original: + indexPath.section = transformSection(indexPath.section) + case .afterUpdates: + break + } + + return indexPath + } + + private func transformSection(_ section: Int) -> Int { + let groupsRemoved = changeset.groupsRemoved + let groupsInserted = changeset.groupsInserted + let availableSpaces = (0.. = [] + internal var groupsRemoved: Set = [] + internal var elementsRemoved: Set = [] + internal var elementsInserted: Set = [] + internal var elementsMoved: Set = [] + internal var elementsUpdated: Set = [] +} diff --git a/Tests/ComposedUITests/ChangesReducerTests.swift b/Tests/ComposedUITests/ChangesReducerTests.swift new file mode 100644 index 0000000..db8ebc2 --- /dev/null +++ b/Tests/ComposedUITests/ChangesReducerTests.swift @@ -0,0 +1,1240 @@ +import XCTest +import Composed +@testable import ComposedUI + +final class ChangesReducerTests: XCTestCase { + func testMultipleElementRemovals() { + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements(at: [IndexPath(item: 0, section: 0)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [IndexPath(item: 0, section: 0)] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements( + at: [ + IndexPath(item: 3, section: 0), + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + ] + ) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 3, section: 0), + IndexPath(item: 4, section: 0), + ] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements( + at: [ + IndexPath(item: 8, section: 0), + ] + ) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 3, section: 0), + IndexPath(item: 4, section: 0), + IndexPath(item: 13, section: 0), + ] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements( + at: [ + IndexPath(item: 4, section: 0), + ] + ) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 3, section: 0), + IndexPath(item: 4, section: 0), + IndexPath(item: 9, section: 0), + IndexPath(item: 13, section: 0), + ] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements( + at: [ + IndexPath(item: 0, section: 0), + ] + ) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 3, section: 0), + IndexPath(item: 4, section: 0), + IndexPath(item: 5, section: 0), + IndexPath(item: 9, section: 0), + IndexPath(item: 13, section: 0), + ] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements( + at: [ + IndexPath(item: 1, section: 0), + ] + ) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 3, section: 0), + IndexPath(item: 4, section: 0), + IndexPath(item: 5, section: 0), + IndexPath(item: 7, section: 0), + IndexPath(item: 9, section: 0), + IndexPath(item: 13, section: 0), + ] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements( + at: [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + ] + ) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 3, section: 0), + IndexPath(item: 4, section: 0), + IndexPath(item: 5, section: 0), + IndexPath(item: 6, section: 0), + IndexPath(item: 7, section: 0), + IndexPath(item: 8, section: 0), + IndexPath(item: 9, section: 0), + IndexPath(item: 13, section: 0), + ] + ) + }) + } + + func testInsertAndRemovalInSameSection() { + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + /** + - Element A + - Element B + - Element C + */ + + AssertApplyingUpdates( + { changesReducer in + changesReducer.insertElements(at: [IndexPath(item: 3, section: 0)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsRemoved.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsInserted, + [IndexPath(item: 3, section: 0)] + ) + }) + + /** + - Element A + - Element B + - Element C + - Element D + */ + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements(at: [IndexPath(item: 0, section: 0)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsInserted, + [IndexPath(item: 2, section: 0)] + ) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + ] + ) + }) + + /** + - Element B + - Element C + - Element D + */ + + AssertApplyingUpdates( + { changesReducer in + changesReducer.insertElements(at: [IndexPath(item: 0, section: 0)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsInserted, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 2, section: 0), + ] + ) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + ] + ) + }) + + /** + - New Element + - Element B + - Element C + - Element D + */ + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements(at: [IndexPath(item: 2, section: 0)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsInserted, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + ] + ) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 0, section: 0), + IndexPath(item: 2, section: 0), + ] + ) + }) + + /** + - New Element + - Element B + - Element D + */ + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements(at: [IndexPath(item: 0, section: 0)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.elementsInserted, + [ + IndexPath(item: 1, section: 0), + ] + ) + XCTAssertEqual( + changeset.elementsRemoved, + [ + IndexPath(item: 2, section: 0), + IndexPath(item: 0, section: 0), + ] + ) + }) + + /** + - Element B + - Element D + */ + } + + func testRemoveSectionThenSwapElements() { + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + /** + - Section A + - Section B + - Section C + - Section C-0 + - Section C-1 + - Section C-2 + - Section C-3 + - Section C-4 + */ + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeGroups([0]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsRemoved.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.groupsRemoved, + [0] + ) + }) + + /** + - Section B + - Section C + - Section C-0 + - Section C-1 + - Section C-2 + - Section C-3 + - Section C-4 + */ + + AssertApplyingUpdates( + { changesReducer in + // Simulate a swap + changesReducer.updateElements(at: [ + IndexPath(item: 0, section: 1), + ]) + changesReducer.updateElements(at: [ + IndexPath(item: 3, section: 1), + ]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertEqual( + changeset.elementsUpdated, + [ + IndexPath(item: 0, section: 2), + IndexPath(item: 3, section: 2), + ] + ) + XCTAssertEqual( + changeset.groupsRemoved, + [0] + ) + }) + } + + func testGroupAndElementRemoves() { + /** + Because element removals are processed before group removals, any element removals that are + performed after a group removal should have their section increased. + */ + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeGroups(IndexSet([0, 1])) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.groupsRemoved, + [0, 1] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeElements(at: [IndexPath(item: 1, section: 1)]) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.groupsRemoved, + [0, 1] + ) + XCTAssertEqual( + changeset.elementsRemoved, + [IndexPath(row: 1, section: 3)] + ) + }) + + AssertApplyingUpdates( + { changesReducer in + changesReducer.removeGroups(IndexSet(integer: 0)) + }, + changesReducer: &changesReducer, + produces: { changeset in + guard let changeset = changeset else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsInserted.isEmpty) + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertEqual( + changeset.groupsRemoved, + [0, 1, 2] + ) + XCTAssertEqual( + changeset.elementsRemoved, + [IndexPath(row: 1, section: 3)] + ) + }) + } + + func testRemoveSectionThenRemoveElementThenRemoveSection() { + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + changesReducer.removeGroups([1]) + changesReducer.removeElements(at: [IndexPath(row: 1, section: 2)]) + changesReducer.removeGroups([1]) + } + + func testMoveElement() { + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + changesReducer.moveElements([(from: IndexPath(row: 0, section: 0), to: IndexPath(row: 1, section: 0))]) + + let changeset = changesReducer.endUpdating() + + XCTAssertNotNil(changeset) + + XCTAssertEqual( + changeset!.elementsMoved, + [ + Changeset.Move( + from: IndexPath(row: 0, section: 0), + to: IndexPath(row: 1, section: 0) + ) + ] + ) + XCTAssertTrue(changeset!.elementsRemoved.isEmpty) + XCTAssertTrue(changeset!.elementsInserted.isEmpty) + XCTAssertTrue(changeset!.groupsInserted.isEmpty) + XCTAssertTrue(changeset!.groupsRemoved.isEmpty) + } + + func testGroupAndElementInserts() { + var changesReducer = ChangesReducer() + changesReducer.beginUpdating() + + changesReducer.insertElements(at: [IndexPath(item: 5, section: 2)]) + changesReducer.insertGroups([1]) + changesReducer.insertElements(at: [IndexPath(item: 6, section: 3)]) + + guard let changeset = changesReducer.endUpdating() else { + XCTFail("Changeset should not be `nil`") + return + } + + XCTAssertTrue(changeset.elementsMoved.isEmpty) + XCTAssertTrue(changeset.elementsRemoved.isEmpty) + XCTAssertEqual( + changeset.elementsInserted, + [ + IndexPath(row: 5, section: 3), + IndexPath(row: 6, section: 3), + ] + ) + XCTAssertEqual( + changeset.groupsInserted, + [1] + ) + XCTAssertTrue(changeset.groupsRemoved.isEmpty) + } + + // MARK:- Unfinished Tests + +// func testGroupInserts() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// changesReducer.insertGroups(IndexSet([0, 2])) +// +// changesReducer.insertGroups(IndexSet(integer: 1)) +// +// let changeset = changesReducer.endUpdating() +// +// XCTAssertNotNil(changeset) +// +// XCTAssertEqual(changeset!.groupsInserted, [0, 0, 2]) +// XCTAssertTrue(changeset!.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset!.elementsRemoved.isEmpty) +// XCTAssertTrue(changeset!.elementsInserted.isEmpty) +// XCTAssertTrue(changeset!.elementsMoved.isEmpty) +// } +// +// func testGroupRemoves() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// changesReducer.removeGroups(IndexSet([0, 2])) +// changesReducer.removeGroups(IndexSet(integer: 0)) +// let changeset = changesReducer.endUpdating() +// +// XCTAssertNotNil(changeset) +// +// XCTAssertEqual(changeset!.groupsRemoved, [0, 1, 2]) +// XCTAssertTrue(changeset!.groupsInserted.isEmpty) +// XCTAssertTrue(changeset!.elementsRemoved.isEmpty) +// XCTAssertTrue(changeset!.elementsInserted.isEmpty) +// XCTAssertTrue(changeset!.elementsMoved.isEmpty) +// } +// +// func testElementRemovalAfterOtherChanges() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// changesReducer.removeGroups([0]) +// changesReducer.insertGroups([2]) +// changesReducer.moveElements([Changeset.Move(from: IndexPath(item: 3, section: 1), to: IndexPath(item: 3, section: 1))]) +// changesReducer.insertElements(at: [IndexPath(item: 5, section: 2)]) +// changesReducer.insertGroups([1]) +// changesReducer.insertElements(at: [IndexPath(item: 6, section: 3)]) +// +// guard let changeset = changesReducer.endUpdating() else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.elementsRemoved.isEmpty) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 5, section: 3), +// IndexPath(row: 6, section: 3), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [1] +// ) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// } +// +// func testMoveElementThenRemoveElementBeforeMovedElement() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// /** +// This is testing: +// +// - A +// - B +// - C +// - D +// +// # Swap C and D +// +// - A +// - B +// - D +// - C +// +// # Delete A +// +// - B +// - D +// - C +// */ +// +// changesReducer.moveElements([(from: IndexPath(row: 2, section: 0), to: IndexPath(row: 3, section: 0))]) +// changesReducer.removeElements(at: [IndexPath(row: 0, section: 0)]) +// +// let changeset = changesReducer.endUpdating() +// +// XCTAssertNotNil(changeset) +// +// XCTAssertEqual( +// changeset!.elementsMoved, +// [ +// Changeset.Move( +// from: IndexPath(row: 1, section: 0), +// to: IndexPath(row: 2, section: 0) +// ) +// ] +// ) +// XCTAssertEqual(changeset!.elementsRemoved, [IndexPath(row: 0, section: 0)]) +// XCTAssertTrue(changeset!.elementsInserted.isEmpty) +// XCTAssertTrue(changeset!.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset!.groupsRemoved.isEmpty) +// } +// +// func testRemoveAnIndexPathWithAMoveTo() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// /** +// This is testing: +// +// - A +// - B +// - C +// +// # Swap B and C +// +// - A +// - C +// - B +// +// # Delete B +// +// - A +// - C +// +// `UICollectionView` does not support deleting an index path and moving to the same index path, so this should produce: +// +// - Delete 1 +// - Delete 2 +// - Insert 1 +// */ +// +// changesReducer.moveElements([(from: IndexPath(row: 1, section: 0), to: IndexPath(row: 2, section: 0))]) +// changesReducer.removeElements(at: [IndexPath(row: 2, section: 0)]) +// +// guard let changeset = changesReducer.endUpdating() else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 1, section: 0), +// IndexPath(row: 2, section: 0), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 1, section: 0), +// ] +// ) +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// } +// +// func testRemoveAnIndexPathWithAMoveFrom() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// /** +// This is testing: +// +// - A +// - B +// - C +// +// # Swap B and C +// +// - A +// - C +// - B +// +// # Delete B +// +// - A +// - C +// +// `UICollectionView` does not support deleting an index path and moving to the same index path, so this should produce: +// +// - Delete 1 +// - Delete 2 +// - Insert 1 +// */ +// +// changesReducer.moveElements([(from: IndexPath(row: 2, section: 0), to: IndexPath(row: 1, section: 0))]) +// changesReducer.removeElements(at: [IndexPath(row: 2, section: 0)]) +// +// guard let changeset = changesReducer.endUpdating() else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 1, section: 0), +// IndexPath(row: 2, section: 0), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 1, section: 0), +// ] +// ) +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// } +// +// func testMoveElementAtSameIndexAsRemove() { +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// /** +// This is testing: +// +// - A +// - B +// - C +// +// # Delete B +// +// - A +// - C +// +// # Swap A and C +// +// - C +// - A +// +// `UICollectionView` does not support deleting an index path and moving to the same index path, so this should produce: +// +// - Update 0 +// - Update 1 +// - Delete 2 +// */ +// +// changesReducer.removeElements(at: [IndexPath(row: 1, section: 0)]) +// changesReducer.moveElements([(from: IndexPath(row: 0, section: 0), to: IndexPath(row: 1, section: 0))]) +// +// let changeset = changesReducer.endUpdating() +// +// XCTAssertNotNil(changeset) +// +// XCTAssertEqual( +// changeset!.elementsRemoved, +// [ +// IndexPath(row: 2, section: 0), +// ] +// ) +// XCTAssertTrue(changeset!.elementsInserted.isEmpty) +// XCTAssertEqual( +// changeset!.elementsUpdated, +// [ +// IndexPath(row: 0, section: 0), +// IndexPath(row: 1, section: 0), +// ] +// ) +// XCTAssertTrue(changeset!.elementsMoved.isEmpty) +// XCTAssertTrue(changeset!.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset!.groupsRemoved.isEmpty) +// } +// +// func testBuildingUpComplexChanges() { +// /** +// This test continuously builds upon the same `ChangesReducer` to test +// applying a large number of changes at the same time. +// +// These changes are mirrored by the `CollectionCoordinatorTests.testBatchUpdates`. +// */ +// var changesReducer = ChangesReducer() +// changesReducer.beginUpdating() +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.insertGroups([0]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsRemoved.isEmpty) +// XCTAssertTrue(changeset.elementsInserted.isEmpty) +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.insertElements(at: [IndexPath(item: 0, section: 0)]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsRemoved.isEmpty) +// XCTAssertTrue(changeset.elementsInserted.isEmpty) +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.updateElements(at: [IndexPath(item: 1, section: 1), IndexPath(item: 2, section: 1)]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 1, section: 0), +// IndexPath(row: 2, section: 0), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 1, section: 1), +// IndexPath(row: 2, section: 1), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.insertGroups([1]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 1, section: 0), +// IndexPath(row: 2, section: 0), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 1, section: 2), +// IndexPath(row: 2, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0, 1] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.removeElements(at: [IndexPath(item: 2, section: 3)]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 1, section: 0), +// IndexPath(row: 2, section: 0), +// IndexPath(row: 2, section: 1), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 1, section: 2), +// IndexPath(row: 2, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0, 1] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.insertGroups([5, 4]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 1, section: 0), +// IndexPath(row: 2, section: 0), +// IndexPath(row: 2, section: 1), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 1, section: 2), +// IndexPath(row: 2, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0, 1, 4, 5] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.removeGroups([1]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset.groupsUpdated.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 0, section: 3), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 0, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsUpdated, +// [ +// IndexPath(row: 1, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.removeElements(at: [IndexPath(item: 2, section: 1)]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsRemoved.isEmpty) +// XCTAssertTrue(changeset.groupsUpdated.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 2, section: 1), +// IndexPath(row: 0, section: 3), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 0, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsUpdated, +// [ +// IndexPath(row: 1, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.removeGroups([2]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsUpdated.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 0, section: 3), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 0, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsUpdated, +// [ +// IndexPath(row: 1, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0] +// ) +// XCTAssertEqual( +// changeset.groupsRemoved, +// [1] +// ) +// }) +// +// AssertApplyingUpdates( +// { changesReducer in +// changesReducer.insertElements(at: [IndexPath(item: 2, section: 1), IndexPath(item: 3, section: 1)]) +// }, +// changesReducer: &changesReducer, +// produces: { changeset in +// guard let changeset = changeset else { +// XCTFail("Changeset should not be `nil`") +// return +// } +// +// XCTAssertTrue(changeset.elementsMoved.isEmpty) +// XCTAssertTrue(changeset.groupsUpdated.isEmpty) +// XCTAssertEqual( +// changeset.elementsRemoved, +// [ +// IndexPath(row: 0, section: 3), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsInserted, +// [ +// IndexPath(row: 0, section: 2), +// IndexPath(row: 2, section: 1), +// IndexPath(row: 3, section: 1), +// ] +// ) +// XCTAssertEqual( +// changeset.elementsUpdated, +// [ +// IndexPath(row: 1, section: 2), +// ] +// ) +// XCTAssertEqual( +// changeset.groupsInserted, +// [0] +// ) +// XCTAssertEqual( +// changeset.groupsRemoved, +// [1] +// ) +// }) +// +// changesReducer.removeElements(at: [IndexPath(item: 2, section: 1)]) +// changesReducer.insertGroups([1, 2, 4]) +// changesReducer.removeGroups([2]) +// } +} + +private func AssertApplyingUpdates(_ updates: (inout ChangesReducer) -> Void, changesReducer: inout ChangesReducer, produces resultChecker: (Changeset?) -> Void) { + updates(&changesReducer) + + var changesReducerCopy = changesReducer + let changeset = changesReducerCopy.endUpdating() + + resultChecker(changeset) +} diff --git a/Tests/ComposedUITests/CollectionCoordinator.swift b/Tests/ComposedUITests/CollectionCoordinator.swift deleted file mode 100644 index f02b2eb..0000000 --- a/Tests/ComposedUITests/CollectionCoordinator.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -import Composed -@testable import ComposedUI - -final class CollectionCoordinatorTests: XCTestCase { - - func testFoo() { - XCTAssert(true) - } - -} diff --git a/Tests/ComposedUITests/CollectionCoordinatorTests.swift b/Tests/ComposedUITests/CollectionCoordinatorTests.swift new file mode 100644 index 0000000..65151e8 --- /dev/null +++ b/Tests/ComposedUITests/CollectionCoordinatorTests.swift @@ -0,0 +1,440 @@ +import XCTest +import Composed +@testable import ComposedUI + +final class CollectionCoordinatorTests: XCTestCase { + /// A series of updates that are performed in batch. This isn't testing `CollectionCoordinator` as much as + /// it tests `ChangesReducer`. These tests are closer to end-to-end tests, essentially testing that the updates from + /// sections are correctly passed up to the `ChangesReducer`, and that the `ChangesReducer` provides the correct updates + /// to `UICollectionView`. + /// + /// One way for these tests to fail is by `UICollectionView` throwing a `NSInternalInconsistencyException', reason: 'Invalid update...'` + /// error, which would likely indicate an error in `ChangesReducer`. + /// + /// It may also fail without throwing an exception, instead logging: `Invalid update: invalid ... - will perform reloadData`. + func testBatchUpdates() { + let tester = Tester() { sections in + sections.rootSectionProvider.append(sections.child0) + sections.rootSectionProvider.append(sections.child1) + sections.rootSectionProvider.append(sections.child2) + sections.rootSectionProvider.append(sections.child3) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 3 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child0) + } + + /** + - Child 1 + - Child 2 + - Child 3 + */ + + tester.applyUpdate({ sections in + sections.child2.swapAt(0, 3) + }, postUpdateChecks: { sections in + XCTAssertEqual(Set(sections.child2.requestedCells), Set([0, 3])) + }) + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child0, at: 0) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 3 + */ + + tester.applyUpdate { sections in + sections.child0.append("new-0") + } + + tester.applyUpdate({ sections in + sections.child2[1] = "new-1" + }, postUpdateChecks: { sections in + XCTAssertEqual(Set(sections.child2.requestedCells), Set([0, 1, 3])) + }) + + tester.applyUpdate({ sections in + sections.child2[2] = "new-2" + }, postUpdateChecks: { sections in + XCTAssertEqual(Set(sections.child2.requestedCells), Set([0, 1, 2, 3])) + }) + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child3) + } + + /** + - Child 0 + - Child 1 + - Child 2 + */ + + tester.applyUpdate { sections in + sections.child2.append("appended") + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child4, at: 1) + } + + /** + - Child 0 + - Child 4 + - Child 1 + - Child 2 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.append(sections.child6) + } + + /** + - Child 0 + - Child 4 + - Child 1 + - Child 2 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child5, before: sections.child6) + } + + /** + - Child 0 + - Child 4 + - Child 1 + - Child 2 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child4) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child4, before: sections.child5) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 4 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child3, before: sections.child4) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 3 + - Child 4 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.child3.remove(at: 2) + } + + tester.applyUpdate { sections in + sections.child3.insert("new-2", at: 2) + } + + tester.applyUpdate { sections in + sections.child3.insert("new-3", at: 3) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child2) + } + + /** + - Child 0 + - Child 1 + - Child 3 + - Child 4 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child3) + } + + /** + - Child 0 + - Child 1 + - Child 4 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child2, at: 2) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 4 + - Child 5 + - Child 6 + */ + + tester.applyUpdate { sections in + sections.child5.swapAt(0, 8) + } + + tester.applyUpdate { sections in + sections.child2.swapAt(0, 3) + } + } + + func testBatchedSectionRemovals() { + let tester = Tester() { sections in + sections.rootSectionProvider.append(sections.child0) + sections.rootSectionProvider.append(sections.child1) + sections.rootSectionProvider.append(sections.child2) + sections.rootSectionProvider.append(sections.child3) + sections.rootSectionProvider.append(sections.child4) + sections.rootSectionProvider.append(sections.child5) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 3 + - Child 4 + - Child 5 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child0) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child3) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child5) + } + + /** + - Child 1 + - Child 2 + - Child 4 + */ + + tester.applyUpdate { sections in + _ = sections.child4.remove(at: 0) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child4) + } + + tester.applyUpdate { sections in + sections.child4.append("appended") + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child2) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child1) + } + } + + func testGroupAndElementRemoves() { + // Mirror of `ChangesReducerTests.testGroupAndElementRemoves` + let tester = Tester() { sections in + sections.child0.removeAll() + sections.child1.removeAll() + sections.child2.removeAll() + + sections.rootSectionProvider.append(sections.child0) + sections.rootSectionProvider.append(sections.child1) + sections.rootSectionProvider.append(sections.child2) + sections.rootSectionProvider.append(sections.child3) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child1) + sections.rootSectionProvider.remove(sections.child0) + } + + tester.applyUpdate { sections in + sections.child3.remove(at: 1) + } + + tester.applyUpdate { sections in + sections.rootSectionProvider.remove(sections.child2) + } + } + + func testInsertInToSectionAfterInsertion() { + let tester = Tester() { sections in + sections.rootSectionProvider.append(sections.child0) + sections.rootSectionProvider.append(sections.child1) + sections.rootSectionProvider.append(sections.child3) + } + + tester.applyUpdate { sections in + sections.child0.append("new-element") + } + + tester.applyUpdate { sections in + sections.child3.append("new-element") + } + + /** + - Child 0 + - Child 1 + - Child 3 + */ + + tester.applyUpdate { sections in + sections.rootSectionProvider.insert(sections.child2, after: sections.child1) + } + + /** + - Child 0 + - Child 1 + - Child 2 + - Child 3 + */ + + tester.applyUpdate { sections in + sections.child0.append("new-element") + } + + tester.applyUpdate { sections in + sections.child3.append("new-element") + } + } + + func testSwapping() { + let tester = Tester() { sections in + sections.rootSectionProvider.append(sections.child2) + } + + tester.applyUpdate { sections in + sections.child2.swapAt(0, 3) + } + } + + func testRemoveAll() { + let tester = Tester() { sections in + sections.rootSectionProvider.append(sections.child2) + } + + tester.applyUpdate { sections in + sections.child2.removeAll() + } + } + + func testRemoveLast2() { + let tester = Tester() { sections in + sections.rootSectionProvider.append(sections.child2) + } + + tester.applyUpdate { sections in + sections.child2.removeLast(2) + } + } +} + +private final class MockCollectionArraySection: ArraySection, CollectionSectionProvider { + private(set) var requestedCells: [Int] = [] + + func section(with traitCollection: UITraitCollection) -> CollectionSection { + let cell = CollectionCellElement(section: self, dequeueMethod: .fromClass(UICollectionViewCell.self), configure: { [weak self] _, cellIndex, _ in + self?.requestedCells.append(cellIndex) + }) + return CollectionSection(section: self, cell: cell) + } +} + +private final class TestSections { + let rootSectionProvider = ComposedSectionProvider() + + let child0 = MockCollectionArraySection([]) + let child1 = MockCollectionArraySection([]) + var child2 = MockCollectionArraySection(["0", "1", "2", "3"]) + let child3 = MockCollectionArraySection(["0", "1", "2"]) + let child4 = MockCollectionArraySection(["0"]) + var child5 = MockCollectionArraySection(["0", "1", "2", "3", "4", "5", "6", "7", "8"]) + let child6 = MockCollectionArraySection([]) +} + +private final class Tester { + typealias Updater = (TestSections) -> Void + + private var updaters: [Updater] = [] + + private var sections: TestSections + + private let initialState: Updater + + private var collectionViews: [UICollectionView] = [] + + init(initialState: @escaping Updater) { + self.initialState = initialState + + sections = TestSections() + } + + func applyUpdate(_ updater: @escaping Updater, postUpdateChecks: ((TestSections) -> Void)? = nil) { + updaters.append(updater) + sections = TestSections() + initialState(sections) + + let rootSectionProvider = sections.rootSectionProvider + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collectionViews.append(collectionView) + + let collectionCoordinator = CollectionCoordinator(collectionView: collectionView, sectionProvider: rootSectionProvider) + collectionCoordinator.enableLogs = true + collectionView.reloadData() + + rootSectionProvider.updateDelegate?.willBeginUpdating(rootSectionProvider) + + updaters.forEach { $0(sections) } + + rootSectionProvider.updateDelegate?.didEndUpdating(rootSectionProvider) + + postUpdateChecks?(sections) + } +}