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] = [] /// The direction each crossing was told it was — the argument the skip banner's verb comes from. private(set) var directions: [HistoryDirection] = [] /// 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 as stale — 13's skip, without needing a foreign writer. func staleStep(_ name: String) -> HistoryStep { step(name, undo: .skipped, redo: .applied) } /// A step whose inverse could not be written — the disk-error fate, which is not staleness. func failingStep(_ name: String) -> HistoryStep { step(name, undo: .failed, redo: .applied) } func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep { HistoryStep( name: name, undo: { [weak self] direction in self?.crossings.append("undo \(name)") self?.directions.append(direction) return undo }, redo: { [weak self] direction in self?.crossings.append("redo \(name)") self?.directions.append(direction) 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 step whose write failed stays on the stack, and the crossing stops there") func aFailedStepStaysAndStopsTheCrossing() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move Card")) provider.register(log.failingStep("Rename Card")) provider.undo() // It was reached and it declined — and unlike a stale step it is still there to retry, with // the step below it untouched underneath. #expect(log.crossings == ["undo Rename Card"], "no fall-through: a refused disk is not a reason to try more") #expect(provider.canUndo) #expect(provider.undoActionName == "Rename Card") #expect(provider.canRedo == false, "nothing landed, so nothing is redoable") provider.undo() #expect(log.crossings == ["undo Rename Card", "undo Rename Card"], "⌘Z can retry it") } @Test("A failed redo leaves the redo stack alone too") func aFailedRedoStays() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Move Card", undo: .applied, redo: .failed)) provider.undo() provider.redo() #expect(provider.canRedo, "still there to retry") #expect(provider.redoActionName == "Move Card") #expect(provider.canUndo == false) } @Test("A step is told which command it is being crossed by, not which half is running") func stepsAreToldTheDirection() { let log = StepLog() let provider = NativeHistoryProvider() provider.register(log.step("Rename Card")) provider.undo() provider.redo() provider.undo() // The third crossing runs the *undo* half again, and the second runs the half registered as // `redo` — what each is told is ⌘Z, ⇧⌘Z, ⌘Z, which is what the skip banner has to say. #expect(log.directions == [.undo, .redo, .undo]) } @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("A read-only board disables both directions, whatever the stack holds") func theLockDisablesEnablement() { final class Lock { var isOn = false } let lock = Lock() let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn }) provider.canUndo = true provider.canRedo = true #expect(manager.canUndo) lock.isOn = true #expect(manager.canUndo == false, "disabled with every other mutating command") #expect(manager.canRedo == false) #expect(provider.canUndo, "an enablement answer, not a clearing — the stack survives") lock.isOn = false #expect(manager.canUndo, "and resumes when the lock clears") } @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"), clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) ) 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 == true) #expect(secondSession.history?.canUndo == false) secondSession.undoManager.undo() #expect(log.crossings.isEmpty) #expect(firstSession.history?.canUndo == true, "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 = try #require(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 session's manager answers this board's own read-only lock") func theSessionWiresTheLock() 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)) session.history?.register(StepLog().step("Move Card")) #expect(session.undoManager.canUndo) session.store.enterVanishedRootLock() #expect(session.undoManager.canUndo == false) #expect(session.history?.canUndo == true, "the stack itself survives the lock") session.store.handleWatcherEvent(.treeChanged(.appMediated)) await session.store.awaitQuiescence() #expect(session.undoManager.canUndo, "and resumes when it clears") } @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") } @Test("The tier reaches the composition root, and the session records what it composed under") func theTierIsAComposedFact() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } // 12-editions.md ▸ The entitlement: the tier is read at composition, once, and handed to the // root that binds the provider. What this pins is that the *argument arrives* — the matrix it // feeds is `GitUndoBindingTests`', since after the re-ruling of 2026-07-31 the tier decides // only whether a git state is composed at all, and the mode decides the substrate. var seen: [Tier] = [] model.currentTier = { .pro } model.makeHistoryProvider = { _, tier, _ in seen.append(tier) return NativeHistoryProvider() } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) #expect(seen == [.pro]) #expect(session.tier == .pro, "the session carries the fact it composed under") } @Test("A tier change never reaches a session that is already open") func aLapseNeverRebindsAnOpenSession() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } model.currentTier = { .pro } let ref = try openBoard(model, at: fixture.root) #expect(try #require(model.session(for: ref)).tier == .pro) // The subscription lapses mid-session — the one thing 12 ▸ The entitlement says must not // disturb a board that is already on screen: "an open board finishes with the provider it // composed; the next open composes the native stack over inert `.git`". model.currentTier = { .free } #expect(try #require(model.session(for: ref)).tier == .pro) } @Test("The purchase flow's reopen ends every session, because that is what recomposing means") func reopeningEndsTheSessions() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } let ref = try openBoard(model, at: fixture.root) #expect(model.hasOpenBoards) // 12 ▸ The entitlement: "Subscribe takes effect at each board's next open ... The purchase // flow offers to reopen open boards." There is no rebinding-in-place to test for, and that // is the finding: reopening *is* ending the session and composing a new one, so what this // pins is the ending. The reopen half needs SwiftUI's window actions, which this host has // none of — the URLs simply buffer until an opener exists (`AppModel.openBoard`), which is // the same path a cold-launch Finder open already takes. await model.reopenOpenBoards() #expect(model.session(for: ref) == nil) #expect(model.hasOpenBoards == false) #expect(model.storeRegistry.openBoardCount == 0, "the store and its watcher went with the session") } } // MARK: - The command surface /// **What the Edit menu's Undo/Redo rows and the toolbar's pair actually do** — driven through the /// platform machinery they ride on rather than described (11-command-nexus.md ▸ Menu commands, the /// M− row; 03-board-ui.md ▸ Toolbar; 13-native-undo.md ▸ Rules). /// /// ### The app writes none of this, which is exactly why it is tested /// /// There is no custom Undo/Redo menu code anywhere: the rows are the system's own nil-target /// `undo:`/`redo:`, and the toolbar's two items carry the same selectors with the same nil target /// (`BoardToolbar`). Every claim the design makes about them — they enable on a stack with steps, /// they dim under the read-only lock, the *menu* rows retitle themselves to "Undo Move 3 Cards" /// while the *toolbar* labels stay static — is therefore a claim about `NSWindow`'s own validation /// reading the manager this app's window delegate hands back. Nothing here would fail loudly if the /// wiring came undone; it would just quietly stop working, which is what these tests are for. /// /// ### What a headless run can and cannot reach /// /// `NSWindow.validateMenuItem(_:)` and `NSWindow.validateUserInterfaceItem(_:)` are the two methods /// AppKit calls once a nil-target lookup has resolved to the window, and both answer fully in a test /// process — which is the half this app owns and the half that can break. The lookup *itself* /// (`NSApp.target(forAction:to:from:)`) needs a **key window**, and a unit-test host has none, so /// "the board window is what the chain resolves to when it is key" is the one link these tests /// cannot close; it is standard responder-chain behaviour with no code of this app's in it. @MainActor @Suite("History ▸ the command surface") struct UndoCommandSurfaceTests { /// A scratch window fronted by the app's own delegate proxy, answering with `manager` — the /// board window's wiring exactly (`BoardWindowHost` sets the same closure). private func hostedWindow(_ manager: UndoManager?) -> (NSWindow, HostedWindowController) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled, .closable], backing: .buffered, defer: true ) let controller = HostedWindowController() controller.boardUndoManager = { manager } controller.attach(to: window) return (window, controller) } private func menuItem(_ selector: String) -> NSMenuItem { NSMenuItem(title: selector == "undo:" ? "Undo" : "Redo", action: NSSelectorFromString(selector), keyEquivalent: "") } // MARK: The menu rows @Test("The Edit menu's rows read the board's stack, and retitle themselves from its step names") func theMenuRowsReadTheBoardsStack() { let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) let (window, controller) = hostedWindow(manager) defer { controller.detach() } let undoRow = menuItem("undo:") let redoRow = menuItem("redo:") #expect(window.undoManager === manager, "the window's answer is the session's adapter") // Nothing to cross: both rows disabled, and titled with the bare verbs. #expect(window.validateMenuItem(undoRow) == false) #expect(window.validateMenuItem(redoRow) == false) #expect(undoRow.title == "Undo") #expect(redoRow.title == "Redo") provider.canUndo = true provider.undoActionName = "Move 3 Cards" // 13's "the 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's // dynamic retitling": the app never writes that string — the platform composes it from the // bare phrase the seam vends, and validation is when it lands on the row. #expect(window.validateMenuItem(undoRow)) #expect(undoRow.title == "Undo Move 3 Cards") #expect(window.validateMenuItem(redoRow) == false) #expect(redoRow.title == "Redo") provider.canRedo = true provider.redoActionName = "Rename Lane" #expect(window.validateMenuItem(redoRow)) #expect(redoRow.title == "Redo Rename Lane") // And it tracks the stack, rather than being set once: crossing a step renames the row. provider.undoActionName = "Delete Card" #expect(window.validateMenuItem(undoRow)) #expect(undoRow.title == "Undo Delete Card") } @Test("The read-only lock dims both rows and leaves their names standing") func theLockDimsTheRowsWithoutRenamingThem() { final class Lock { var isOn = false } let lock = Lock() let provider = FakeHistoryProvider() provider.canUndo = true provider.undoActionName = "Move Card" provider.canRedo = true provider.redoActionName = "Rename Card" let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn }) let (window, controller) = hostedWindow(manager) defer { controller.detach() } let undoRow = menuItem("undo:") let redoRow = menuItem("redo:") #expect(window.validateMenuItem(undoRow)) #expect(window.validateMenuItem(redoRow)) lock.isOn = true #expect(window.validateMenuItem(undoRow) == false, "disabled with every other mutating command") #expect(window.validateMenuItem(redoRow) == false) #expect(undoRow.title == "Undo Move Card", "a disabled row keeps its name — the stack survives the lock") #expect(redoRow.title == "Redo Rename Card") lock.isOn = false #expect(window.validateMenuItem(undoRow), "and resumes when it clears") } @Test("A window with no board has nothing to undo, and says so with the bare verb") func aBoardlessWindowHasNothingToCross() { let (window, controller) = hostedWindow(nil) defer { controller.detach() } let undoRow = menuItem("undo:") #expect(window.validateMenuItem(undoRow) == false) #expect(undoRow.title == "Undo") } @Test("Every window over one board answers with that board's one stack") func cardWindowsShareTheBoardsStack() 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)) // The board window and one of its card windows, wired the way their hosts wire them. let (boardWindow, boardController) = hostedWindow(session.undoManager) defer { boardController.detach() } let (cardWindow, cardController) = hostedWindow(session.undoManager) defer { cardController.detach() } let boardRow = menuItem("undo:") let cardRow = menuItem("undo:") session.history?.register(StepLog().step("Move 3 Cards")) #expect(boardWindow.validateMenuItem(boardRow)) #expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window") #expect(boardRow.title == "Undo Move 3 Cards") #expect(cardRow.title == boardRow.title) } // MARK: The toolbar twins @Test("The toolbar pair validates identically to the menu rows — and keeps its static labels") func theToolbarPairMatchesTheMenuRows() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation()) let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) let (window, hosted) = hostedWindow(manager) defer { hosted.detach() } /// The real items, built by the real delegate — nil target, `undo:`/`redo:` actions. func item(_ identifier: NSToolbarItem.Identifier) throws -> NSToolbarItem { try #require(controller.toolbar( controller.toolbar, itemForItemIdentifier: identifier, willBeInsertedIntoToolbar: true )) } let undoItem = try item(.boardUndo) let redoItem = try item(.boardRedo) #expect(undoItem.target == nil, "nil target: the chain resolves it, exactly as the menu row's is") #expect(redoItem.target == nil) // `validateUserInterfaceItem` is what `NSToolbarItem.validate()` asks its resolved target, // and `validateMenuItem` is what a menu row's asks — one predicate, two doors. #expect(window.validateUserInterfaceItem(undoItem) == false) #expect(window.validateUserInterfaceItem(redoItem) == false) provider.canUndo = true provider.undoActionName = "Move 3 Cards" provider.canRedo = true provider.redoActionName = "Rename Lane" let undoRow = menuItem("undo:") let redoRow = menuItem("redo:") #expect(window.validateUserInterfaceItem(undoItem) == window.validateMenuItem(undoRow)) #expect(window.validateUserInterfaceItem(redoItem) == window.validateMenuItem(redoRow)) #expect(window.validateUserInterfaceItem(undoItem)) #expect(window.validateUserInterfaceItem(redoItem)) // 03's one exception to the label rule, proven rather than asserted: validation rewrote the // *menu* row's title and left the toolbar item's label exactly where it was. #expect(undoRow.title == "Undo Move 3 Cards") #expect(undoItem.label == "Undo") #expect(redoItem.label == "Redo") #expect(undoItem.paletteLabel == "Undo", "the customize palette shows the static label too") } @Test("A toolbar item's own validation lands on the board's answer, lock included") func theToolbarItemValidatesThroughTheWindow() throws { final class Lock { var isOn = false } let lock = Lock() let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let toolbar = BoardToolbar.controller(store: store, search: BoardSearchPresentation()) let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn }) let (window, hosted) = hostedWindow(manager) defer { hosted.detach() } let item = try #require(toolbar.toolbar( toolbar.toolbar, itemForItemIdentifier: .boardUndo, willBeInsertedIntoToolbar: true )) // The one link a headless run cannot make: `NSToolbarItem.validate()` resolves its target // through the key window, and a test host has none. Standing the window in as the target is // that lookup's *answer* — which is what nil-target means when a board window is key — so // what this asserts is the item's own validation path, end to end from `validate()`. item.target = window #expect(item.autovalidates, "AppKit revalidates it on user events; the observation covers the rest") item.validate() #expect(item.isEnabled == false, "an empty stack dims it") provider.canUndo = true item.validate() #expect(item.isEnabled) lock.isOn = true item.validate() #expect(item.isEnabled == false, "the lock disables the toolbar pair with the menu rows") lock.isOn = false item.validate() #expect(item.isEnabled) #expect(item.label == "Undo", "no crossing of validation ever moves the label") } @Test("The pair's enablement is deliberately not a predicate of the toolbar's own") func theSpecsAbstainFromEnablement() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation()) // Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A // spec-level `isEnabled` here would be a second answer able to disagree with the responder // chain's — and it would have to read a stack the toolbar has no route to, since the board's // window is what owns that answer. for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] { let spec = try #require(specs.first { $0.identifier == identifier }) #expect(spec.isEnabled, "abstention, not enablement: AppKit's own validation decides") #expect(spec.isOn == nil) } } }