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