Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar pair takes explicit targets, both reading the focused session's BoardUndoManager through FocusedValues.undoStack (board windows publish the session's manager, card windows their own) — the nil-target route died with the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface, re-ruled 2026-08-08. The rows enact the routing predicate themselves: text focus routes ⌘Z to the first responder's own manager, title and enablement included, re-derived at fire time with a beep for the stale window. NativeHistoryProvider turns @Observable so both surfaces re-derive on stack changes; a checkpoint-notification ticker covers plain text managers. .responderAction leaves ToolbarItemSpec with its only user; windowWillReturnUndoManager stays wired for AppKit's own asks. Live-probed on the fixture board (21/21): the row retitles to "Undo Add Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server chord, the toolbar pair validates and fires, search-field and body-editor ⌘Z stay text undo with board stacks untouched, and a card window crosses its own stack with no fall-through. 2698 unit tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
170 lines
7.9 KiB
Swift
170 lines
7.9 KiB
Swift
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")
|
|
}
|
|
}
|