diff --git a/Package.swift b/Package.swift index 16b8fa2..273f8e4 100644 --- a/Package.swift +++ b/Package.swift @@ -11,6 +11,7 @@ let package = Package( products: [ .library(name: "SQLiteUndo", targets: ["SQLiteUndo"]), .library(name: "SQLiteUndoTCA", targets: ["SQLiteUndoTCA"]), + .library(name: "SQLiteUndoTestHelpers", targets: ["SQLiteUndoTestHelpers"]), ], dependencies: [ .package( @@ -36,10 +37,21 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), + .target( + name: "SQLiteUndoTestHelpers", + dependencies: [ + "SQLiteUndo", + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SQLiteData", package: "sqlite-data"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), + ] + ), .testTarget( name: "SQLiteUndoTests", dependencies: [ "SQLiteUndo", + "SQLiteUndoTestHelpers", .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "DependenciesTestSupport", package: "swift-dependencies"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), diff --git a/Sources/SQLiteUndo/UndoStack.swift b/Sources/SQLiteUndo/UndoStack.swift index a05667e..fdda43d 100644 --- a/Sources/SQLiteUndo/UndoStack.swift +++ b/Sources/SQLiteUndo/UndoStack.swift @@ -49,6 +49,12 @@ public struct UndoStack: Sendable { /// For the `.live()` stack, this updates which UndoManager receives registrations. /// For the test stack, this is a no-op. public var setUndoManager: @Sendable (_ undoManager: UndoManager?) -> Void = { _ in } + + /// Perform the most recent undo action. + /// + /// In tests, this calls the last `onUndo` closure captured from `registerBarrier`. + /// In production, this calls `undoManager?.undo()`. + public var performUndo: @MainActor @Sendable () throws -> Void } extension DependencyValues { @@ -69,6 +75,7 @@ extension UndoStack: DependencyKey { public static var testValue: UndoStack { let state = LockIsolated(UndoStackState(undo: [])) + let lastOnUndo = LockIsolated<(@Sendable () throws -> Void)?>(nil) return UndoStack( registerBarrier: { barrier, onUndo, onRedo in @@ -76,6 +83,7 @@ extension UndoStack: DependencyKey { $0.undo.append(barrier.name) $0.redo = [] } + lastOnUndo.setValue(onUndo) }, currentState: { UndoStackState( @@ -83,7 +91,20 @@ extension UndoStack: DependencyKey { redo: state.value.redo.reversed() ) }, - setUndoManager: { _ in } + setUndoManager: { _ in }, + performUndo: { + guard let onUndo = lastOnUndo.value else { + reportIssue("No undo action registered") + return + } + try onUndo() + state.withValue { + if let last = $0.undo.popLast() { + $0.redo.append(last) + } + } + lastOnUndo.setValue(nil) + } ) } @@ -217,6 +238,9 @@ extension UndoStack: DependencyKey { } else { logger.warning("setUndoManager: nil") } + }, + performUndo: { + target.undoManager?.undo() } ) } diff --git a/Sources/SQLiteUndoTestHelpers/AssertUndoable.swift b/Sources/SQLiteUndoTestHelpers/AssertUndoable.swift new file mode 100644 index 0000000..8b9bf93 --- /dev/null +++ b/Sources/SQLiteUndoTestHelpers/AssertUndoable.swift @@ -0,0 +1,190 @@ +import CustomDump +import Dependencies +import InlineSnapshotTesting +import SQLiteData +import SQLiteUndo +import SnapshotTestingCustomDump + +/// Asserts that an operation is undoable by verifying: +/// 1. The database state before the operation (inline snapshot) +/// 2. The undo stack contains the expected action name +/// 3. The database state after the operation (inline snapshot) +/// 4. Performing undo restores the original database state +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +@MainActor +public func assertUndoable>( + _ actionName: String, + query: S, + fileID: StaticString = #fileID, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column, + operation: () async throws -> Void, + before: (() -> String)? = nil, + after: (() -> String)? = nil +) async throws { + @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultUndoStack) var undoStack + + // 1. Snapshot the before state + let beforeTable = fetchTable(query, database: database) + assertInlineSnapshot( + of: beforeTable, + as: .lines, + message: "Before did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "before", + trailingClosureOffset: 1 + ), + matches: before, + fileID: fileID, + file: file, + function: function, + line: line, + column: column + ) + + // 2. Perform the operation + try await operation() + + // 3. Verify the undo stack + expectNoDifference( + undoStack.currentState(), + UndoStackState(undo: [actionName]), + fileID: fileID, + filePath: file, + line: line, + column: column + ) + + // 4. Snapshot the after state + let afterTable = fetchTable(query, database: database) + assertInlineSnapshot( + of: afterTable, + as: .lines, + message: "After did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "after", + trailingClosureOffset: 2 + ), + matches: after, + fileID: fileID, + file: file, + function: function, + line: line, + column: column + ) + + // 5. Perform undo and verify it restores the original state + do { + try undoStack.performUndo() + } catch { + reportIssue( + "Undo failed: \(error)", + fileID: fileID, + filePath: file, + line: line, + column: column + ) + return + } + let afterUndoTable = fetchTable(query, database: database) + expectNoDifference( + afterUndoTable, + beforeTable, + "Undo did not restore the original state", + fileID: fileID, + filePath: file, + line: line, + column: column + ) +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func fetchTable>( + _ query: S, + database: any DatabaseWriter +) -> String { + do { + let rows = try database.write { try query.fetchAll($0) } + if rows.isEmpty { + return "(No results)" + } + var table = "" + printTable(rows, to: &table) + return table + } catch { + return "(Error: \(error.localizedDescription))" + } +} + +private func printTable( + _ rows: [(repeat each C)], to output: inout some TextOutputStream +) { + var maxColumnSpan: [Int] = [] + var hasMultiLineRows = false + for _ in repeat (each C).self { + maxColumnSpan.append(0) + } + var table: [([[Substring]], maxRowSpan: Int)] = [] + for row in rows { + var columns: [[Substring]] = [] + var index = 0 + var maxRowSpan = 0 + for column in repeat each row { + defer { index += 1 } + var cell = "" + customDump(column, to: &cell) + let lines = cell.split(separator: "\n") + hasMultiLineRows = hasMultiLineRows || lines.count > 1 + maxRowSpan = max(maxRowSpan, lines.count) + maxColumnSpan[index] = max(maxColumnSpan[index], lines.map(\.count).max() ?? 0) + columns.append(lines) + } + table.append((columns, maxRowSpan)) + } + guard !table.isEmpty else { return } + output.write("┌─") + output.write( + maxColumnSpan + .map { String(repeating: "─", count: $0) } + .joined(separator: "─┬─") + ) + output.write("─┐\n") + for (offset, rowAndMaxRowSpan) in table.enumerated() { + let (row, maxRowSpan) = rowAndMaxRowSpan + for rowOffset in 0.. any DatabaseWriter { + let database = try DatabaseQueue(configuration: Configuration()) + try database.write { db in + try db.execute( + sql: """ + CREATE TABLE "testRecords" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '' + ) + """ + ) + } + return database +}