import AppKit import Foundation import Testing @testable import Kanban /// The provider seam and the board stack behind it (12-editions.md ▸ The provider seam; /// 13-native-undo.md). /// /// The steps here are **synthetic** on purpose: what this milestone builds is the stack and the /// seam, and the real inverses arrive at the Writer boundary in the next one. A step that records /// its own crossing is therefore the exact fixture — it proves the stack's grammar (one gesture one /// step, undo flips to redo, a stale step falls through) without needing a board to move cards /// around on. // MARK: - Synthetic steps /// Records every crossing, in order, and answers with whatever outcome the test asked for. @MainActor private final class StepLog { private(set) var crossings: [String] = [] /// A step that applies in both directions — the ordinary case. func step(_ name: String) -> HistoryStep { step(name, undo: .applied, redo: .applied) } /// A step whose inverse declines — 13's staleness skip, without needing a foreign writer. func staleStep(_ name: String) -> HistoryStep { step(name, undo: .skipped, redo: .applied) } func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep { HistoryStep( name: name, undo: { [weak self] in self?.crossings.append("undo \(name)") return undo }, redo: { [weak self] in self?.crossings.append("redo \(name)") return redo } ) } } // MARK: - The native stack @MainActor @Suite("History ▸ the native stack") struct NativeHistoryProviderTests { @Test("A fresh provider has nothing to cross and nothing to say about it") func emptyStack() { let provider = NativeHistoryProvider() #expect(provider.canUndo == false) #expect(provider.canRedo == false) #expect(provider.undoActionName == nil) #expect(provider.redoActionName == nil) } @Test("Registering a step arms Undo and names it — and runs nothing") func registerArmsUndo() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move Card")) #expect(provider.canUndo) #expect(provider.canRedo == false) #expect(provider.undoActionName == "Move Card") #expect(provider.redoActionName == nil) #expect(log.crossings.isEmpty, "registration is not application") } @Test("Undo runs the inverse once and flips the step onto Redo, keeping its name") func undoRunsTheInverseAndFlipsToRedo() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move 3 Cards")) provider.undo() #expect(log.crossings == ["undo Move 3 Cards"]) #expect(provider.canUndo == false) #expect(provider.canRedo) // The phrase names the gesture, not the direction — "Undo Move 3 Cards" becomes // "Redo Move 3 Cards". #expect(provider.redoActionName == "Move 3 Cards") #expect(provider.undoActionName == nil) } @Test("Redo replays the write and arms Undo again — the classic dance, both ways") func redoReplaysAndFlipsBack() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Rename Lane")) provider.undo() provider.redo() #expect(log.crossings == ["undo Rename Lane", "redo Rename Lane"]) #expect(provider.canUndo) #expect(provider.canRedo == false) #expect(provider.undoActionName == "Rename Lane") provider.undo() #expect(log.crossings == ["undo Rename Lane", "redo Rename Lane", "undo Rename Lane"]) } @Test("One register call is one step — two registrations are two crossings, newest first") func oneRegistrationIsOneStep() { let log = StepLog() let provider = NativeHistoryProvider() // Back to back, in one turn of the run loop: `groupsByEvent` must not fold these into one. provider.register(log.step("Move Card")) provider.register(log.step("Rename Card")) #expect(provider.undoActionName == "Rename Card") provider.undo() #expect(log.crossings == ["undo Rename Card"]) #expect(provider.undoActionName == "Move Card") provider.undo() #expect(log.crossings == ["undo Rename Card", "undo Move Card"]) #expect(provider.canUndo == false) } @Test("A new step clears the redo stack — classic behaviour") func registeringClearsRedo() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move Card")) provider.undo() #expect(provider.canRedo) provider.register(log.step("Delete Card")) #expect(provider.canRedo == false) #expect(provider.redoActionName == nil) #expect(provider.undoActionName == "Delete Card") } @Test("Undo on an empty stack does nothing at all") func undoOnAnEmptyStackIsInert() { let log = StepLog() let provider = NativeHistoryProvider() provider.undo() provider.redo() #expect(log.crossings.isEmpty) #expect(provider.canUndo == false) #expect(provider.canRedo == false) } @Test("A stale step is skipped, not applied — and ⌘Z falls through to the next one") func aStaleStepIsSkippedAndFallsThrough() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move Card")) provider.register(log.staleStep("Rename Card")) provider.undo() // Both were reached in one ⌘Z: the stale one declined and was dropped, the next one applied. #expect(log.crossings == ["undo Rename Card", "undo Move Card"]) #expect(provider.canUndo == false) // Only the step that actually ran is redoable — a skipped step leaves nothing behind. #expect(provider.canRedo) #expect(provider.redoActionName == "Move Card") } @Test("A stack of nothing but stale steps empties itself and stops") func anEntirelyStaleStackEmptiesItself() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.staleStep("Move Card")) provider.register(log.staleStep("Rename Card")) provider.undo() #expect(log.crossings == ["undo Rename Card", "undo Move Card"]) #expect(provider.canUndo == false) #expect(provider.canRedo == false) } @Test("Clearing drops both directions — the session-only rule's one call") func clearEmptiesBothStacks() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move Card")) provider.undo() provider.register(log.step("Delete Card")) provider.clear() #expect(provider.canUndo == false) #expect(provider.canRedo == false) #expect(provider.undoActionName == nil) #expect(provider.redoActionName == nil) provider.undo() #expect(log.crossings == ["undo Move Card"], "nothing crossed after the clear") } @Test("Two providers are two stacks — undo is board-local by construction") func providersAreIndependent() { let log = StepLog() let one = NativeHistoryProvider() let other = NativeHistoryProvider() one.register(log.step("Move Card")) #expect(one.canUndo) #expect(other.canUndo == false) other.undo() #expect(log.crossings.isEmpty, "the other board's ⌘Z crosses nothing of this board's") #expect(one.canUndo) } } // MARK: - The AppKit adapter /// A provider with no `NSUndoManager` anywhere in it — which is the point: `BoardUndoManager` is /// tested against *this* rather than against the native stack, because what has to be true is that /// the adapter works for any implementation of the seam (Pro's git provider binds the same protocol /// in pro-m1). @MainActor private final class FakeHistoryProvider: HistoryProviding { var canUndo = false var canRedo = false var undoActionName: String? var redoActionName: String? private(set) var registered: [String] = [] private(set) var undoCount = 0 private(set) var redoCount = 0 private(set) var clearCount = 0 func register(_ step: HistoryStep) { registered.append(step.name) } func undo() { undoCount += 1 } func redo() { redoCount += 1 } func clear() { clearCount += 1 } } @MainActor @Suite("History ▸ the AppKit adapter") struct BoardUndoManagerTests { @Test("Enablement is the provider's answer, not a stack of the adapter's own") func enablementMirrorsTheProvider() { let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) #expect(manager.canUndo == false) #expect(manager.canRedo == false) provider.canUndo = true provider.canRedo = true #expect(manager.canUndo) #expect(manager.canRedo) } @Test("The menu titles are the platform's composition over the step's own phrase") func menuTitlesComposeFromTheStepName() { let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) // Nothing to cross: the bare verb, with no trailing space where the name would go. #expect(manager.undoMenuItemTitle == "Undo") #expect(manager.redoMenuItemTitle == "Redo") provider.canUndo = true provider.undoActionName = "Move 3 Cards" provider.canRedo = true provider.redoActionName = "Rename Lane" #expect(manager.undoActionName == "Move 3 Cards") #expect(manager.undoMenuItemTitle == "Undo Move 3 Cards") #expect(manager.redoMenuItemTitle == "Redo Rename Lane") } @Test("Crossing forwards to the provider — what ⌘Z and the toolbar item actually reach") func crossingForwards() { let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) manager.undo() manager.undo() manager.redo() #expect(provider.undoCount == 2) #expect(provider.redoCount == 1) } @Test("A stray registration into the adapter can never be crossed or shown") func theAdaptersOwnStackStaysInert() { let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) let sink = FakeHistoryProvider() manager.registerUndo(withTarget: sink) { $0.canUndo = true } #expect(manager.canUndo == false, "the provider is the only source of truth") #expect(manager.undoMenuItemTitle == "Undo") manager.undo() #expect(sink.canUndo == false, "the stray action was never run") #expect(provider.undoCount == 1) } @Test("Clearing the adapter's own stack leaves the board's alone") func removeAllActionsDoesNotClearTheBoard() { let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) provider.canUndo = true manager.removeAllActions() #expect(provider.clearCount == 0) #expect(manager.canUndo) } } // MARK: - Routing @MainActor @Suite("History ▸ undo routing") struct BoardUndoRoutingTests { @Test("A text view — field editors included — is a text-editing surface; a plain view is not") func textEditingClassification() { #expect(BoardUndoRouting.isTextEditing(NSTextView())) #expect(BoardUndoRouting.isTextEditing(NSTextField()) == false, "focused, not yet editing") #expect(BoardUndoRouting.isTextEditing(NSView()) == false) #expect(BoardUndoRouting.isTextEditing(NSWindow()) == false) #expect(BoardUndoRouting.isTextEditing(nil) == false) } @Test("With focus outside every text surface, a board window answers with the board's stack") func boardFocusAnswersTheBoardStack() { let board = BoardUndoManager(history: FakeHistoryProvider()) let fallback = UndoManager() let answer = BoardUndoRouting.undoManager(isTextEditing: false, board: board, textFallback: fallback) #expect(answer === board) } @Test("A focused field editor answers with the window's text manager, never the board's") func fieldEditorFocusAnswersTheTextManager() { let board = BoardUndoManager(history: FakeHistoryProvider()) let fallback = UndoManager() let answer = BoardUndoRouting.undoManager(isTextEditing: true, board: board, textFallback: fallback) #expect(answer === fallback) } @Test("A window with no board answers with the text manager either way") func windowsWithNoBoardFallBack() { let fallback = UndoManager() #expect(BoardUndoRouting.undoManager(isTextEditing: false, board: nil, textFallback: fallback) === fallback) #expect(BoardUndoRouting.undoManager(isTextEditing: true, board: nil, textFallback: fallback) === fallback) } } // MARK: - The session that owns the stack /// A board on disk, and an `AppModel` whose registry file is in temp rather than in the test host's /// real Application Support directory — `AppModelTests`' two helpers, in the shape this suite needs. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) return fixture } @MainActor private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("HistoryProviderTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json")) return (model, { try? FileManager.default.removeItem(at: folder) }) } @MainActor @discardableResult private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { let ref = BoardWindowRef(url: url) let recordID = model.boardRegistry.recordOpen(of: url) let store = try model.storeRegistry.acquire(url) model.boardRegistry.setOpenNow(id: recordID) model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) return ref } @MainActor @Suite("History ▸ the board session's stack") struct BoardSessionHistoryTests { @Test("A session is born with a stack, and its adapter is a face for that same stack") func aSessionOwnsOneStack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) let log = StepLog() #expect(session.undoManager.canUndo == false) session.history.register(log.step("Move Card")) // The window hands AppKit the adapter; the adapter is answering from the session's provider. #expect(session.undoManager.canUndo) #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card") session.undoManager.undo() #expect(log.crossings == ["undo Move Card"]) #expect(session.undoManager.canRedo) } @Test("Two open boards are two stacks — never one another's") func sessionsAreIsolated() throws { let first = try makeBoard() defer { first.tearDown() } let second = try makeBoard() defer { second.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } let firstRef = try openBoard(model, at: first.root) let secondRef = try openBoard(model, at: second.root) let log = StepLog() let firstSession = try #require(model.session(for: firstRef)) let secondSession = try #require(model.session(for: secondRef)) #expect(firstSession.history !== secondSession.history) firstSession.history.register(log.step("Move Card")) #expect(firstSession.history.canUndo) #expect(secondSession.history.canUndo == false) secondSession.undoManager.undo() #expect(log.crossings.isEmpty) #expect(firstSession.history.canUndo, "the other board's ⌘Z left this one's stack alone") } @Test("Closing a board empties its stack — session-only persistence") func closingClearsTheStack() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) let history = session.history let log = StepLog() history.register(log.step("Move Card")) #expect(history.canUndo) await model.closeBoard(ref: ref, cause: .userClose) #expect(model.session(for: ref) == nil) #expect(history.canUndo == false, "reopening the board starts empty") #expect(history.canRedo == false) #expect(log.crossings.isEmpty) } @Test("The composition root decides which provider a session gets") func theProviderIsBoundAtComposition() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } let bound = FakeHistoryProvider() model.makeHistoryProvider = { _ in bound } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) #expect(session.history === bound) session.undoManager.undo() #expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound") } }