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)
+ }
+}