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") } }