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:
2026-07-28 15:29:45 -04:00
parent 50669489cb
commit 96c4014fef
8 changed files with 531 additions and 19 deletions
+264
View File
@@ -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)
}
}
}
+240
View File
@@ -107,6 +107,41 @@ private func untouchedLines(_ text: String) -> [Substring] {
}
}
/// Every `index.md` on the board, keyed by its folder's path relative to the root the whole of
/// what a round trip has to land back on, files-are-truth read literally.
///
/// Read as raw bytes off disk rather than through the loader, like every other assertion in this
/// file: the board being *equivalent* is not the claim, the board being the same bytes is.
private func boardTexts(_ fixture: WriterFixture) throws -> [String: String] {
var texts: [String: String] = [:]
let rootDepth = fixture.root.standardizedFileURL.pathComponents.count
guard let walker = FileManager.default.enumerator(at: fixture.root, includingPropertiesForKeys: nil) else {
return texts
}
for case let url as URL in walker where url.lastPathComponent == "index.md" {
let folder = url.deletingLastPathComponent().standardizedFileURL
let relative = folder.pathComponents.dropFirst(rootDepth).joined(separator: "/")
texts[relative] = try String(decoding: Data(contentsOf: url), as: UTF8.self)
}
return texts
}
/// Asserts two whole-board readings are the same board the same items, and every file identical
/// but for the stamps every app write owns.
///
/// **"Byte-identical" is byte-identical-except-`modified`**, and deliberately so: an inverse is an
/// ordinary app-mediated write (13-native-undo.md), so it stamps on the way past, and 13 promises
/// state equivalence rather than mtime equality. That is exactly the precision
/// `WriteFidelityTombstoneTests.deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp`
/// established for the deleterestore round trip, applied here to a whole board.
private func expectSameBoard(_ actual: [String: String], _ expected: [String: String], _ label: String) {
#expect(actual.keys.sorted() == expected.keys.sorted(), "\(label): the board holds different items")
for (path, text) in expected {
guard let landed = actual[path] else { continue }
#expect(untouchedLines(landed) == untouchedLines(text), "\(label): '\(path)' came back different")
}
}
// MARK: - Resize
@MainActor
@@ -1276,3 +1311,208 @@ struct HistoryPhraseTests {
}
}
}
// MARK: - The trash's two doors
/// 13-native-undo.md Interaction with the trash: "'s undo is the move back ... a restore-by-move
/// undoes the same way in reverse. **The stack and the trash never conflict** they are the same
/// folder moves addressed by recency instead of by selection."
///
/// The per-operation round trips live in `TrashUndoTests` above. What is here is the *interplay*:
/// that the two doors reach one state rather than two similar ones, and that alternating between
/// them leaves a stack that crosses cleanly in both directions.
@MainActor
@Suite("Undo ▸ the trash's two doors")
struct TrashInterplayTests {
@Test("Undoing a delete lands exactly where Put Back would have — the same bytes, not a near miss")
func theTwoDoorsReachOneState() async throws {
// Two identical boards, one per door: the claim is about a *state*, so the honest comparison
// is the whole board read off disk, not the one field each path happens to write.
let byUndo = try makeBoard()
defer { byUndo.tearDown() }
let byPutBack = try makeBoard()
defer { byPutBack.tearDown() }
let origin = try boardTexts(byUndo)
let (undoStore, history) = try makeStore(byUndo)
undoStore.delete([card1])
history.undo()
let (putBackStore, _) = try makeStore(byPutBack)
putBackStore.delete([card1])
// Put Back reads the trashed side of the snapshot, so it has to see the tombstone first
// which is the one-way flow, not a test artefact.
await reload(putBackStore)
putBackStore.putBack([card1])
expectSameBoard(try boardTexts(byUndo), try boardTexts(byPutBack), "the two doors")
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
#expect(try document(byUndo, card1Path).deleted.isMissing)
#expect(try document(byPutBack, card1Path).deleted.isMissing)
}
@Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end")
func undoRestoresInPlace() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
/// The lane's live cards in display order, read through the loader the order the board
/// actually renders, rather than the ranks it is derived from.
func liveCards() throws -> [String] {
let result = try BoardLoader.load(boardRoot: fixture.root)
let lane = try #require(result.model.lanes.first { $0.id == lane1 })
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
}
let before = try liveCards()
#expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle")
// The middle of three: an implementation that restored by appending would pass on a first or
// last card and fail here.
store.delete([card2])
await reload(store)
#expect(try liveCards() == [Ident.card1, Ident.card3])
history.undo()
#expect(try liveCards() == before, "01's deletion bullet: restore is position-perfect")
#expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along")
}
@Test("Alternating the two doors leaves a stack that crosses cleanly, both ways")
func interleavedDoorsCrossBothWays() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let live = try boardTexts(fixture)
// Three gestures over one card, alternating the doors: , Put Back, .
store.delete([card1])
await reload(store)
let filedUnder = try #require(try document(fixture, card1Path).deleted.value)
store.putBack([card1])
await reload(store)
#expect(try document(fixture, card1Path).deleted.isMissing)
store.delete([card1])
await reload(store)
#expect(history.undoActionName == "Delete Card")
let trashed = try boardTexts(fixture)
// Back up the stack: delete restore tombstone restore. The middle step is Put Back's
// inverse, which has to re-file the row under the stamp it was filed under rather than under
// now the trash sorts by it.
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
history.undo()
#expect(try document(fixture, card1Path).deleted.value == filedUnder,
"re-tombstoned where it was filed, not where a fresh stamp would put it")
#expect(history.undoActionName == "Delete Card")
history.undo()
#expect(history.canUndo == false)
expectSameBoard(try boardTexts(fixture), live, "three doors back")
// And forward again, the same three steps in the other direction.
history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil)
history.redo()
#expect(try document(fixture, card1Path).deleted.isMissing)
history.redo()
#expect(history.canRedo == false)
expectSameBoard(try boardTexts(fixture), trashed, "three doors forward")
#expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided")
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - The capstone
/// **A whole session, unwound and wound back up.** 13-native-undo.md's promise, taken literally: a
/// user creates, moves, styles, renames and deletes without touching the mouse, then holds Z until
/// the board is the one they opened and Z back to the one they built.
///
/// The assertion is the *board*, read off disk in full: every `index.md`, byte for byte but for the
/// `modified` stamps every app write owns (`expectSameBoard`, whose note records why that is the
/// right precision an inverse is an ordinary write, and 13 promises state equivalence rather than
/// mtime equality).
///
/// Five gestures rather than one of each kind on purpose: they all name the **same card**, so each
/// step's staleness predicate is validated against a board four other steps have since written to.
/// A chain that only worked on untouched targets would pass a suite of five isolated round trips and
/// fail here.
@MainActor
@Suite("Undo ▸ the full session")
struct FullSessionUndoTests {
@Test("Create, move, style, rename, delete — then ⌘Z back to the origin and ⇧⌘Z forward again")
func theWholeSessionRoundTrips() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let origin = try boardTexts(fixture)
// 1 create. The one gesture whose undo removes a folder rather than rewriting one.
store.transient.beginPlaceholder(inLane: lane1, after: card1)
store.transient.updateDraft("Fresh")
let created = try #require(store.commitPlaceholder())
await reload(store)
#expect(history.undoActionName == "Add Card")
// 2 move it to the other lane.
store.moveCards([created], toLane: lane2, at: 0)
await reload(store)
#expect(history.undoActionName == "Move Card")
// 3 style it.
store.applyStyle(to: .items([created]), background: .set("red"), icon: .set("flag"))
await reload(store)
#expect(history.undoActionName == "Restyle Card")
// 4 rename it.
store.transient.beginRename(of: created, currentTitle: "Fresh")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
await reload(store)
#expect(history.undoActionName == "Rename Card")
// 5 delete it.
store.delete([created])
await reload(store)
#expect(history.undoActionName == "Delete Card")
let built = try boardTexts(fixture)
#expect(built.count == origin.count + 1, "one new card, and nothing else arrived")
// Z ×5. The Edit menu's row renames itself on every press the platform composes
// "Undo <phrase>" from exactly these names (`BoardUndoManager`).
for next in ["Rename Card", "Restyle Card", "Move Card", "Add Card"] {
history.undo()
#expect(history.undoActionName == next)
}
history.undo()
#expect(history.canUndo == false, "five gestures, five steps, no more and no fewer")
#expect(history.redoActionName == "Add Card")
expectSameBoard(try boardTexts(fixture), origin, "unwound")
#expect(store.banners.signposts.isEmpty, "nothing went stale under its own session")
#expect(store.banners.oneShots.isEmpty, "and nothing failed to write")
// Z ×5, back to the board the session built.
for next in ["Move Card", "Restyle Card", "Rename Card", "Delete Card"] {
history.redo()
#expect(history.redoActionName == next)
}
history.redo()
#expect(history.canRedo == false)
#expect(history.undoActionName == "Delete Card")
expectSameBoard(try boardTexts(fixture), built, "rewound")
#expect(store.banners.signposts.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
}