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 (the git provider bound the same protocol, /// in pro-m1, before app-managed git was excised entirely, `strategy/01-git-excision.md`). @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") } } // MARK: - The command surface /// **What a window hands AppKit, and what the board toolbar's Undo/Redo pair reaches** — the two /// halves of the command surface that are still platform-shaped, driven through the machinery they /// ride on rather than described (03-board-ui.md ▸ Toolbar; 13-native-undo.md ▸ Rules). /// /// ### What this suite covers since the surface became the app's own /// /// **The rows are no longer here.** Edit ▸ Undo/Redo are `CommandGroup(replacing: .undoRedo)` rows /// the app writes and routes itself (13 ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08; /// `UndoCommands.swift`, pinned by `UndoCommandsTests.swift`), because the nil-target route they used /// to ride is unreachable on a SwiftUI window: `NSWindow` latches an empty undo manager during /// creation, before `HostedWindowController` installs, so `windowWillReturnUndoManager` is never /// consulted for them. These tests never reproduced that — they attach the delegate before the /// window's first read, which is exactly the ordering a real SwiftUI window denies — and that is the /// diagnosis, not a gap to close: the hook works when it is asked, and the app stopped depending on /// it being asked. /// /// What is pinned below is therefore what remains true and load-bearing: /// /// - **The delegate hook itself**, which stays wired because it is the right answer wherever *AppKit* /// asks a window's delegate for a manager (`HostedWindowController.windowWillReturnUndoManager`). /// `NSWindow.validateMenuItem(_:)` is the sharpest instrument a headless run has for reading what /// that hook returned — enablement, the lock, the composed title, the two levels — so the menu /// rows still appear here as the *probe*, not as the subject. /// - **The toolbar pair's explicit target** (`BoardToolbar`), which validates and fires against the /// session's `BoardUndoManager` directly. That path is the app's own end to end, and it is the one /// a headless run can close completely. @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.windowUndoManager = { manager } controller.attach(to: window) return (window, controller) } /// A row carrying the platform's own `undo:`/`redo:`, used here as a **probe** rather than as a /// shipped surface: validating one against the window is how a test reads back the manager /// `windowWillReturnUndoManager` returned, title composition and all. The app's own rows carry no /// selector at all (`UndoCommands.swift`). private func menuItem(_ selector: String) -> NSMenuItem { NSMenuItem(title: selector == "undo:" ? "Undo" : "Redo", action: NSSelectorFromString(selector), keyEquivalent: "") } // MARK: What the delegate hands back @Test("A window's delegate hands back the board's stack, titles composing 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 vocabulary supplies menu titles ('Undo Move 3 Cards'), via dynamic retitling": // the app never writes that string — the platform composes it from the bare phrase the seam // vends (`BoardUndoManager.undoMenuItemTitle`), and this is where it lands. The app's own // rows read the very same property, one step further out (`UndoCommandRouting.undoTitle`). #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("A board window answers with the board's stack; a card window answers with its own") func eachWindowAnswersWithItsOwnStack() throws { // Realigned 2026-07-31 with the two-level model (13-native-undo.md ▸ Rules): this pinned the // shared-stack wiring, which is exactly what the session-coarsening re-ruling replaced — // "a card window owns its own stack ... and `window.undoManager` answers with it". 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 // (`BoardWindowHost.configureWindow`, `CardWindowHost.configureWindow`). let card = CardWindowUndo() let (boardWindow, boardController) = hostedWindow(session.undoManager) defer { boardController.detach() } let (cardWindow, cardController) = hostedWindow(card.manager) defer { cardController.detach() } let boardRow = menuItem("undo:") let cardRow = menuItem("undo:") session.history?.register(StepLog().step("Move 3 Cards")) #expect(boardWindow.validateMenuItem(boardRow)) #expect(boardRow.title == "Undo Move 3 Cards") // **No fall-through** (13-native-undo.md ▸ Undo routing): the card window's own stack is // empty, so its row is disabled and ⌘Z beeps — it never reaches the board's step. The rows // enforce it the same way now, by reading one focused value that is one window's or the // other's (`FocusedValues.undoStack`) and never both. #expect(cardWindow.validateMenuItem(cardRow) == false) #expect(cardRow.title == "Undo") card.stack.register(StepLog().step("Comment")) #expect(cardWindow.validateMenuItem(cardRow)) #expect(cardRow.title == "Undo Comment") #expect(boardRow.title == "Undo Move 3 Cards", "and the board's row is untouched by it") } // MARK: The toolbar twins /// A board toolbar wired the way `BoardWindowHost` wires one, over `undo`. private func boardToolbar(store: BoardStore, undo: BoardUndoManager?) -> WindowToolbarController { let domain = "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)" return BoardToolbar.controller( store: store, search: BoardSearchPresentation(), zoom: BoardZoomStore(defaults: UserDefaults(suiteName: domain)!), appearance: AppearanceStore(defaults: UserDefaults(suiteName: domain + ".appearance")!, apply: { _ in }), session: DragSession(), undo: undo ) } /// One real item, built by the real delegate. private func toolbarItem( _ controller: WindowToolbarController, _ identifier: NSToolbarItem.Identifier ) throws -> NSToolbarItem { try #require(controller.toolbar( controller.toolbar, itemForItemIdentifier: identifier, willBeInsertedIntoToolbar: true )) } /// **The pair carries an explicit target now** (13-native-undo.md ▸ Rules ▸ the command-surface /// bullet, re-ruled 2026-08-08) — the toolbar controller, over the session's `BoardUndoManager`, /// where until that ruling both items carried nil targets and `undo:`/`redo:` selectors for the /// responder chain to resolve. /// /// This replaces the pin that read the pair's validation *through the window* against the menu /// rows'. The claim it was making — one answer on both surfaces — is unchanged and now stronger: /// they are not two validations that agree, they are one object both of them read /// (`bothSurfacesReadOneManager`). @Test("The toolbar pair targets the session's stack — and keeps its static labels") func theToolbarPairCarriesAnExplicitTarget() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) let controller = boardToolbar(store: store, undo: manager) let undoItem = try toolbarItem(controller, .boardUndo) let redoItem = try toolbarItem(controller, .boardRedo) #expect(undoItem.target === controller, "the app's own target, not the responder chain's lookup") #expect(redoItem.target === controller) undoItem.validate() redoItem.validate() #expect(undoItem.isEnabled == false, "an empty stack dims it") #expect(redoItem.isEnabled == false) provider.canUndo = true provider.undoActionName = "Move 3 Cards" provider.canRedo = true provider.redoActionName = "Rename Lane" undoItem.validate() redoItem.validate() #expect(undoItem.isEnabled) #expect(redoItem.isEnabled) // 03's one exception to the label rule, proven rather than asserted: the phrase the *menu* // composes ("Undo Move 3 Cards") never reaches a toolbar label, whatever validation does. #expect(manager.undoMenuItemTitle == "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 theToolbarItemValidatesThroughTheManager() 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 provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn }) let controller = boardToolbar(store: store, undo: manager) let item = try toolbarItem(controller, .boardUndo) // The link a headless run no longer has to fake: validation used to resolve a nil target // through the key window, which a test host does not have, so the window stood in as the // answer. The item is handed its target at construction now, so this is the shipped path // end to end from `validate()`. #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("Clicking the item crosses the board's stack, through the target it was given") func theToolbarItemCrossesTheStack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let provider = FakeHistoryProvider() provider.canUndo = true provider.canRedo = true let manager = BoardUndoManager(history: provider) let controller = boardToolbar(store: store, undo: manager) let undoItem = try toolbarItem(controller, .boardUndo) let redoItem = try toolbarItem(controller, .boardRedo) let target = try #require(undoItem.target as? NSObject) let action = try #require(undoItem.action) target.perform(action, with: undoItem) #expect(provider.undoCount == 1, "the click reaches the session's stack, not a responder's") let redoTarget = try #require(redoItem.target as? NSObject) redoTarget.perform(try #require(redoItem.action), with: redoItem) #expect(provider.redoCount == 1) } /// **One manager, two faces** — what "the toolbar mirrors the menu" means for this pair now that /// neither of them goes through the responder chain (13-native-undo.md ▸ Rules ▸ the /// command-surface bullet, re-ruled 2026-08-08; `BoardToolbar`'s header). /// /// This is where the retired `theSpecsAbstainFromEnablement` pin went. Its claim — that the pair /// must never grow a second answer able to disagree with the menu's — is the same claim, made /// the only way it can be now that the items *do* carry a predicate: the predicate and the row /// are reading one object, so they cannot come apart. @Test("The toolbar pair and the Edit menu's rows read one and the same manager") func bothSurfacesReadOneManager() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) let specs = BoardToolbar.specs( store: store, search: BoardSearchPresentation(), zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!), appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }), session: DragSession(), undo: manager ) // The row's side of it, derived exactly as `UndoMenuRow` derives it: the focused stack, with // nothing text-shaped holding the keyboard. func rowIsEnabled() -> Bool { UndoCommandRouting.canUndo( UndoCommandRouting.routedManager(stack: manager, firstResponder: nil) ) } for state in [false, true, false] { provider.canUndo = state let spec = try #require(specs.first { $0.identifier == .boardUndo }) #expect(spec.isEnabled == state) #expect(spec.isEnabled == rowIsEnabled(), "one object answers both surfaces") } } }