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:
@@ -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 delete→restore 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user