Wire native undo into menus, toolbar, and command validation
The command surface was already almost entirely platform machinery — this card proves it and pins it. Headless probes established that NSWindow.validateMenuItem answers enablement AND rewrites the row title from the delegate-supplied manager, so 'Undo Move 3 Cards' flows step phrase to Edit menu with no code of ours; under the lock the rows dim and keep their names, the correct reading of the-stack-survives. The toolbar twins validate through validateUserInterfaceItem, which never touches labels — 03's static-label exception proven rather than asserted — and their specs' enablement abstention is pinned so nobody later adds a second, disagreeing answer. The one link a headless run cannot close is the nil-target key-window resolution itself: standard responder-chain behavior with none of our code in it, left as the manual check. Base-edition 'disabled without undo' scaffolding is reworded away — every base board has undo now. New suites cover the trash's two doors (delete-then-undo byte-identical to Put Back's effect), position-preserving restore of a middle card, and the capstone: five gestures forward, five presses back to the origin board, five forward again, the menu phrase asserted after every press. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -594,3 +594,267 @@ struct BoardSessionHistoryTests {
|
||||
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user