import AppKit import Testing @testable import Kanban // MARK: - Fixtures /// A board stack with a real substrate behind it — the pair a session composes (`AppModel`). @MainActor private func makeStack(isReadOnly: @escaping @MainActor () -> Bool = { false }) -> (BoardUndoManager, NativeHistoryProvider) { let provider = NativeHistoryProvider() return (BoardUndoManager(history: provider, isReadOnly: isReadOnly), provider) } /// A step that applies in both directions and does nothing else — this suite's subject is which /// manager a row *reaches*, never what crossing it does to disk. @MainActor private func step(_ name: String) -> HistoryStep { HistoryStep(name: name, undo: { _ in .applied }, redo: { _ in .applied }) } /// A text view's delegate vending a manager of its own — `CardBodySurface` and `CardRawSourceView` /// in one line, which is the shape 13-native-undo.md ▸ Undo routing calls "an editor's /// delegate-vended manager". @MainActor private final class UndoVendingDelegate: NSObject, NSTextViewDelegate { let manager = UndoManager() func undoManager(for view: NSTextView) -> UndoManager? { manager } } // MARK: - Routing /// **Which manager the app's own Undo/Redo rows answer with** (13-native-undo.md ▸ Undo routing, the /// predicate; ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08 — the rule unchanged, its /// enactment moved from the window delegate to the command layer). /// /// The decision is three lines that are invisible until they are wrong, which is `BoardUndoRouting`'s /// own reason for being a pure enum and this suite's for pinning every branch of the one above it. @MainActor @Suite("Undo commands ▸ routing") struct UndoCommandRoutingTests { @Test("With nothing holding the keyboard, a row answers with the focused stack") func noResponderAnswersTheStack() { let (manager, _) = makeStack() #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: nil) === manager) #expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: nil) == nil) } @Test("A responder that is not a text surface is not one — the stack answers") func nonTextResponderAnswersTheStack() { let (manager, _) = makeStack() #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSResponder()) === manager) #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSView()) === manager) // A field that has focus without editing is the board's, not the field editor's: AppKit // installs the field editor only when editing begins (`BoardUndoRouting.isTextEditing`). #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSTextField()) === manager) #expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: NSView()) == nil) } @Test("An editor's own manager wins the keyboard, whatever the board stack holds") func aFocusedEditorAnswersWithItsOwnManager() { let (manager, provider) = makeStack() provider.register(step("Move 3 Cards")) let delegate = UndoVendingDelegate() let editor = NSTextView() editor.allowsUndo = true editor.delegate = delegate let routed = UndoCommandRouting.routedManager(stack: manager, firstResponder: editor) #expect(routed === delegate.manager) // **No fall-through** (13 ▸ Undo routing): the editor's stack is empty and the board's is // not, and the row still answers with the editor's — "exhausting a focused editor's stack // beeps; it never reaches board history". The row that renders over this is disabled, and // the ⌘Z that fires against it beeps. #expect(manager.canUndo, "the board has a step to cross, and the row will not cross it") #expect(UndoCommandRouting.canUndo(routed) == false) #expect(UndoCommandRouting.undoTitle(of: routed) == "Undo", "the editor's bare verb, not the board's phrase") } @Test("A text surface with no manager of its own answers nothing at all") func aTextSurfaceWithNoManagerAnswersNil() { let (manager, provider) = makeStack() provider.register(step("Move 3 Cards")) let editor = NSTextView() editor.allowsUndo = true // No delegate to vend one and no window to inherit one from: the responder chain ends here. // `nil` rather than the board's stack, which is the same no-fall-through rule read from its // other end — a text surface holding the keyboard is never a reason to reach past it. #expect(editor.undoManager == nil) #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: editor) == nil) #expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: editor) == nil) } } // MARK: - Titles and enablement /// **What a rendered row says and whether it is live** — the two halves the rows route along with the /// action, because a row that advertised a step it would not cross, or swallowed ⌘Z while dimmed off /// the wrong stack, is exactly how no-fall-through fails from the user's side (13-native-undo.md /// ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08). @MainActor @Suite("Undo commands ▸ titles and enablement") struct UndoCommandTitleTests { @Test("No manager is the bare verb, disabled — a row with nothing in front of it") func noManagerIsTheBareVerb() { #expect(UndoCommandRouting.undoTitle(of: nil) == "Undo") #expect(UndoCommandRouting.redoTitle(of: nil) == "Redo") #expect(UndoCommandRouting.canUndo(nil) == false) #expect(UndoCommandRouting.canRedo(nil) == false) } @Test("A named step composes the platform's title, and lights the row") func aNamedStepComposesTheTitle() { let (manager, provider) = makeStack() #expect(UndoCommandRouting.undoTitle(of: manager) == "Undo", "an empty stack keeps the bare verb") #expect(UndoCommandRouting.canUndo(manager) == false) provider.register(step("Move 3 Cards")) #expect(UndoCommandRouting.canUndo(manager)) #expect(UndoCommandRouting.undoTitle(of: manager) == "Undo Move 3 Cards") #expect(UndoCommandRouting.canRedo(manager) == false, "nothing crossed yet") provider.undo() #expect(UndoCommandRouting.canUndo(manager) == false) #expect(UndoCommandRouting.canRedo(manager)) #expect(UndoCommandRouting.redoTitle(of: manager) == "Redo Move 3 Cards") } /// The trim is why these helpers exist at all rather than the rows reading `undoMenuItemTitle` /// directly: `BoardUndoManager` trims its own nameless composition, and a plain `NSUndoManager` — /// a text view's, a field editor's — does not, leaving "Undo " with a trailing space on the row. @Test("A nameless plain NSUndoManager still reads as the bare verb") func aNamelessTextManagerTrimsToTheBareVerb() { let text = UndoManager() #expect(UndoCommandRouting.undoTitle(of: text) == "Undo") #expect(UndoCommandRouting.redoTitle(of: text) == "Redo") } /// The read-only lock reaches the rows through the very object they read — one answer, wherever /// it is asked from (13 ▸ Rules ▸ locks; `BoardUndoManager.canUndo`). @Test("The read-only lock dims the row and leaves its name standing") func theLockDimsTheRow() { final class Lock { var isOn = false } let lock = Lock() let (manager, provider) = makeStack(isReadOnly: { lock.isOn }) provider.register(step("Move Card")) #expect(UndoCommandRouting.canUndo(manager)) lock.isOn = true #expect(UndoCommandRouting.canUndo(manager) == false) #expect(UndoCommandRouting.undoTitle(of: manager) == "Undo Move Card", "the stack survives the lock") lock.isOn = false #expect(UndoCommandRouting.canUndo(manager), "and resumes when it clears") } } // MARK: - The ticker's silenced window /// **The reads that post, kept out of the ticker they would otherwise re-enter** — the livelock /// regression (diagnosed 2026-08-08 by sampling a frozen instance). A plain `NSUndoManager` fires /// `NSUndoManagerCheckpoint` synchronously from `canRedo` — and from the composed redo title through /// it (`canUndo` posts nothing, an asymmetry pinned below); the ticker subscribes to exactly that /// notification; and a bump landing mid-`body` re-invalidates the row whose read posted it, forever /// (`UndoCommandTicker.silencingReadEchoes`). `BoardUndoManager` never echoes — its overrides answer /// from the provider — which is why every fixture here is the plain text-manager shape the rows /// route to while an editor holds the keyboard. @MainActor @Suite("Undo commands ▸ ticker") struct UndoCommandTickerTests { @Test("A checkpoint reaches the ticker — the subscription the silence guards against is live") func aGenuineCheckpointBumps() { let before = UndoCommandTicker.shared.revision // `canUndo` answers without posting — the asymmetry that made the redo side the loop's // fuel in the wild, pinned so a platform change is noticed here first. _ = UndoManager().canUndo #expect(UndoCommandTicker.shared.revision == before) // `canRedo` posts the checkpoint synchronously before answering — the very side effect the // silenced window exists for, here arriving unsilenced. _ = UndoManager().canRedo #expect(UndoCommandTicker.shared.revision > before) } @Test("A row's own derivation leaves the ticker still — the livelock regression") func silencedReadsDoNotBump() { let text = UndoManager() let before = UndoCommandTicker.shared.revision let answers = UndoCommandTicker.shared.silencingReadEchoes { (undo: UndoCommandRouting.undoTitle(of: text), redo: UndoCommandRouting.redoTitle(of: text), canUndo: UndoCommandRouting.canUndo(text), canRedo: UndoCommandRouting.canRedo(text)) } #expect(UndoCommandTicker.shared.revision == before, "the reads' own echoes never land") #expect(answers == (undo: "Undo", redo: "Redo", canUndo: false, canRedo: false), "and the answers themselves are untouched by the silence") } @Test("The silence is scoped to the read, and nests") func silenceIsScopedAndNests() { let before = UndoCommandTicker.shared.revision UndoCommandTicker.shared.silencingReadEchoes { UndoCommandTicker.shared.silencingReadEchoes { _ = UndoManager().canRedo } // Still inside the outer window after the inner one closes. _ = UndoManager().canRedo } #expect(UndoCommandTicker.shared.revision == before, "nothing inside the window lands") _ = UndoManager().canRedo #expect(UndoCommandTicker.shared.revision > before, "and the window closes behind the read") } }