Files
lanework/KanbanTests/UndoWriteTests.swift
T
rzen 96c4014fef 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
2026-07-28 15:29:45 -04:00

1519 lines
62 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import Kanban
/// The inverses registered at the Writer boundary (13-native-undo.md ▸ Rules) — one round trip per
/// operation: perform the gesture, cross it backwards, read the **bytes on disk**, cross it forwards
/// again.
///
/// These drive a real store over a real temp board with a real `NativeHistoryProvider` behind it, and
/// assert against the files rather than the snapshot, like every other write suite here. That is the
/// only way to check the claim 13 actually makes: an undo is "an ordinary app-mediated write", not an
/// in-memory revert — so what has to come back is the *file*.
///
/// What "equals prior" means differs by operation, exactly as the design does: **byte-level for a
/// body** (the body span is all a body write touches) and **field-level for frontmatter** (`modified`
/// is stamped by every app write, undo included, so a byte comparison would be asserting the opposite
/// of the storage contract). `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixtures
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
deleted: \(deleted)
---
\(title) body.
"""
}
private let styledCard = """
---
schema: 1
title: Styled
order: 3072
project: lanework # agent overlay
background: blue
icon: star
created: 2026-01-01T09:00:00Z
---
Styled body.
"""
/// Two live lanes — the first with two live cards, a styled one and a tombstoned one; the second
/// with one card. Enough for every inverse in this file.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard)
try fixture.item("\(Ident.lane1)/\(Ident.card4)", tombstoned(order: "4096", title: "Trashed"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let trashed = ItemID(rawValue: Ident.card4)
private let elsewhere = ItemID(rawValue: Ident.indexless)
private let card1Path = "\(Ident.lane1)/\(Ident.card1)"
private let card2Path = "\(Ident.lane1)/\(Ident.card2)"
private let card3Path = "\(Ident.lane1)/\(Ident.card3)"
private let trashedPath = "\(Ident.lane1)/\(Ident.card4)"
/// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak**
/// — the session owns the stack in the app, and a test that dropped it would watch its own steps
/// disappear.
@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
return (store, history)
}
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
/// One item's frontmatter as the app reads it — the level "equals prior" is asserted at for
/// everything but a body.
private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument {
try FrontmatterDocument.parse(fixture.indexText(relativePath))
}
/// The file's lines minus the ones every app-mediated write owns — what an inverse must leave
/// byte-identical, unknown keys and their comments included.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified")
}
}
/// 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
@Suite("Undo ▸ resize")
struct ResizeUndoTests {
@Test("A width change undoes to the prior width and redoes to the new one")
func widthRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.setLaneWidth(lane1, units: 3)
#expect(try document(fixture, Ident.lane1).width.value == 3)
#expect(history.undoActionName == "Resize Lane")
history.undo()
// The lane had no `width` key at all, so "prior" is its absence — not `width: 1`.
#expect(try document(fixture, Ident.lane1).width.isMissing)
history.redo()
#expect(try document(fixture, Ident.lane1).width.value == 3)
}
@Test("An explicit prior width comes back as the value it was, not as the app's default")
func priorValueIsRestoredVerbatim() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.setLaneWidth(lane1, units: 4)
// The second gesture reads its prior value off the snapshot, so it has to see the first —
// which is the one-way flow working, not a test artefact.
await reload(store)
store.setLaneWidth(lane1, units: 2)
#expect(try document(fixture, Ident.lane1).width.value == 2)
history.undo()
#expect(try document(fixture, Ident.lane1).width.value == 4)
}
@Test("A multi-lane step is one step with a plural title")
func batchIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.stepLaneWidths([lane1, lane2], by: 1)
#expect(history.undoActionName == "Resize 2 Lanes")
#expect(try document(fixture, Ident.lane1).width.value == 2)
#expect(try document(fixture, Ident.lane2).width.value == 2)
history.undo()
#expect(try document(fixture, Ident.lane1).width.isMissing)
#expect(try document(fixture, Ident.lane2).width.isMissing)
#expect(history.canUndo == false, "one gesture, one step")
}
}
// MARK: - Restyle
@MainActor
@Suite("Undo ▸ restyle")
struct RestyleUndoTests {
@Test("A style change undoes to the prior values — removing the keys that were not there")
func styleRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let before = try fixture.indexText(card1Path)
store.applyStyle(to: .items([card1]), background: .set("red"), icon: .set("flag"))
#expect(try document(fixture, card1Path).background.value == "red")
#expect(history.undoActionName == "Restyle Card")
history.undo()
let undone = try document(fixture, card1Path)
#expect(undone.background.isMissing)
#expect(undone.icon.isMissing)
// Nothing but the styled keys and the stamp moved — the unknown key with its comment, the
// reserved `labels`, `created`, and the body all came back through untouched.
#expect(untouchedLines(try fixture.indexText(card1Path)) == untouchedLines(before))
history.redo()
#expect(try document(fixture, card1Path).background.value == "red")
#expect(try document(fixture, card1Path).icon.value == "flag")
}
@Test("A prior value is restored as itself, not removed")
func priorStyleValueComesBack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyStyle(to: .items([card3]), background: .set("green"))
history.undo()
let undone = try document(fixture, card3Path)
#expect(undone.background.value == "blue")
#expect(undone.icon.value == "star", "a dimension the gesture did not touch is not touched back")
}
@Test("A styling batch is one step, with a plural title")
func batchIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyStyle(to: .items([card1, card2, card3]), background: .set("red"))
#expect(history.undoActionName == "Restyle 3 Cards")
history.undo()
#expect(try document(fixture, card1Path).background.isMissing)
#expect(try document(fixture, card2Path).background.isMissing)
#expect(try document(fixture, card3Path).background.value == "blue")
#expect(history.canUndo == false)
}
@Test("The board's own styling names the board")
func boardStyleIsNamedForTheBoard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyStyle(to: .board, background: .set("graphite"))
#expect(history.undoActionName == "Restyle Board")
history.undo()
#expect(try document(fixture, "").background.isMissing)
}
}
// MARK: - Rename
@MainActor
@Suite("Undo ▸ rename")
struct RenameUndoTests {
@Test("A card rename undoes to the prior title and redoes to the new one")
func renameRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
#expect(try document(fixture, card1Path).title.value == "Renamed")
#expect(history.undoActionName == "Rename Card")
history.undo()
#expect(try document(fixture, card1Path).title.value == "First")
history.redo()
#expect(try document(fixture, card1Path).title.value == "Renamed")
}
@Test("An emptied title undoes back to the title that was there")
func emptyRenameRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft(" ")
store.commitRename()
#expect(try document(fixture, card1Path).title.isMissing, "an empty commit removes the key")
history.undo()
#expect(try document(fixture, card1Path).title.value == "First")
history.redo()
#expect(try document(fixture, card1Path).title.isMissing)
}
@Test("A lane rename is named for the lane; the board's for the board")
func namesFollowTheLevel() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: lane1, currentTitle: "Todo")
store.transient.updateRenameDraft("Backlog")
store.commitRename()
#expect(history.undoActionName == "Rename Lane")
store.renameBoard("Project")
#expect(history.undoActionName == "Rename Board")
#expect(try document(fixture, "").title.value == "Project")
history.undo()
#expect(try document(fixture, "").title.value == "Board")
history.undo()
#expect(try document(fixture, Ident.lane1).title.value == "Todo")
}
@Test("An unchanged rename writes nothing and registers nothing")
func aNoOpRenameRegistersNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("First")
store.commitRename()
#expect(history.canUndo == false)
}
}
// MARK: - Create
@MainActor
@Suite("Undo ▸ create")
struct CreateUndoTests {
@Test("Undoing a lane create removes the folder; redo puts it back, identity and bytes intact")
func laneCreateRoundTrip() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let before = try fixture.entryNames("")
store.createLane()
let created = try #require(try fixture.entryNames("").first { !before.contains($0) })
let bytes = try fixture.indexData(created)
#expect(history.undoActionName == "Add Lane")
history.undo()
#expect(fixture.exists(created) == false, "an undone create leaves no trace — not a tombstone")
history.redo()
#expect(fixture.exists(created), "the same UUID, so every later step still names something")
#expect(try fixture.indexData(created) == bytes, "replayed verbatim — nothing re-serialized")
}
@Test("Undoing a card create removes the folder, rank and all")
func cardCreateRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let before = try fixture.entryNames(Ident.lane1)
store.transient.beginPlaceholder(inLane: lane1, after: card1)
store.transient.updateDraft("Fresh")
let created = try #require(store.commitPlaceholder())
let path = "\(Ident.lane1)/\(created.rawValue)"
let bytes = try fixture.indexData(path)
#expect(history.undoActionName == "Add Card")
#expect(try fixture.entryNames(Ident.lane1).count == before.count + 1)
history.undo()
#expect(fixture.exists(path) == false)
history.redo()
#expect(try fixture.indexData(path) == bytes, "the rank it was placed at rides in its bytes")
}
}
// MARK: - Move and reorder
@MainActor
@Suite("Undo ▸ move and reorder")
struct MoveUndoTests {
@Test("A cross-lane move undoes to the original lane at the original order")
func moveRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveCards([card1], toLane: lane2, at: 0)
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"))
#expect(fixture.exists(card1Path) == false)
#expect(history.undoActionName == "Move Card")
history.undo()
#expect(fixture.exists(card1Path), "the folder moved back")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)") == false)
#expect(try document(fixture, card1Path).order.value == 1024, "at the rank it left")
history.redo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"))
}
@Test("A multi-card move is one step with a plural title")
func multiCardMoveIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveCards([card1, card2, card3], toLane: lane2, at: 0)
#expect(history.undoActionName == "Move 3 Cards")
history.undo()
#expect(fixture.exists(card1Path))
#expect(fixture.exists(card2Path))
#expect(fixture.exists(card3Path))
#expect(try document(fixture, card1Path).order.value == 1024)
#expect(try document(fixture, card2Path).order.value == 2048)
#expect(try document(fixture, card3Path).order.value == 3072)
#expect(history.canUndo == false, "one gesture, one step")
}
@Test("A drop that stays in its own lane is a Reorder, and undoes to the rank it held")
func sameLaneDropIsAReorder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveCards([card1], toLane: lane1, at: 2)
#expect(history.undoActionName == "Reorder Card")
#expect(try document(fixture, card1Path).order.value != 1024)
history.undo()
#expect(try document(fixture, card1Path).order.value == 1024)
}
@Test("A lane drag undoes to the lane's own prior rank")
func laneReorderRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveLane(lane1, toIndex: 1)
let moved = try #require(try document(fixture, Ident.lane1).order.value)
#expect(moved > 2048)
#expect(history.undoActionName == "Reorder Lane")
history.undo()
#expect(try document(fixture, Ident.lane1).order.value == 1024)
history.redo()
#expect(try document(fixture, Ident.lane1).order.value == moved)
}
@Test("⌥⌘↓ undoes the whole permutation, siblings included")
func sortRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.select([card1], liveness: .live)
store.sortSelection(.down)
#expect(history.undoActionName == "Reorder Card")
#expect(try document(fixture, card1Path).order.value == 2048)
#expect(try document(fixture, card2Path).order.value == 1024)
history.undo()
#expect(try document(fixture, card1Path).order.value == 1024)
#expect(try document(fixture, card2Path).order.value == 2048)
history.redo()
#expect(try document(fixture, card1Path).order.value == 2048)
#expect(try document(fixture, card2Path).order.value == 1024)
}
}
// MARK: - The trash pair
@MainActor
@Suite("Undo ▸ the trash pair")
struct TrashUndoTests {
@Test("Undoing a delete is Put Back — the key goes, and nothing else moves")
func deleteRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let before = try fixture.indexText(card1Path)
store.delete([card1])
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(history.undoActionName == "Delete Card")
history.undo()
let undone = try fixture.indexText(card1Path)
#expect(try FrontmatterDocument.parse(undone).deleted.isMissing)
// Byte-identical but for the stamp: the tombstone and its inverse are one key each.
#expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before))
history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil)
}
@Test("A multi-item delete is one step with a plural title")
func batchDeleteIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1, card2])
#expect(history.undoActionName == "Delete 2 Cards")
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(try document(fixture, card2Path).deleted.isMissing)
#expect(history.canUndo == false)
}
@Test("A lane delete is named for the lane")
func laneDeleteIsNamedForTheLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([lane2])
#expect(history.undoActionName == "Delete Lane")
history.undo()
#expect(try document(fixture, Ident.lane2).deleted.isMissing)
}
@Test("Undoing a Put Back re-tombstones with the timestamp the row was filed under")
func putBackRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
store.putBack([trashed])
#expect(try document(fixture, trashedPath).deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
history.undo()
#expect(try document(fixture, trashedPath).deleted.value == filedUnder,
"the trash sorts by this — a fresh stamp would reorder a list the user was reading")
history.redo()
#expect(try document(fixture, trashedPath).deleted.isMissing)
}
@Test("Drag-to-restore undoes the position half too")
func restoreByDragRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
store.restoreByDrag(cardID: trashed, intoLane: lane2, at: 0)
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
history.undo()
#expect(fixture.exists(trashedPath), "back in the lane it was trashed in")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false)
let undone = try document(fixture, trashedPath)
#expect(undone.deleted.value == filedUnder)
#expect(undone.order.value == 4096, "at the rank it was trashed holding")
history.redo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
}
}
// MARK: - The Edit session
@MainActor
@Suite("Undo ▸ the Edit session")
struct BodyUndoTests {
@Test("A session's saves are one step, registered at the flip, with the body it started from")
func sessionIsOneStep() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body
let session = CardBodyEditSession()
session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished }
session.registerUndo = { [weak store] prior, new in
store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new)
}
session.adopt(diskBody: original)
// Three debounced ticks inside one session — none of them a step.
session.edited("One.\n")
_ = session.flush()
session.edited("One two.\n")
_ = session.flush()
session.edited("One two three.\n")
_ = session.flush()
#expect(history.canUndo == false, "a save tick is not a step")
session.endEditSession()
#expect(history.undoActionName == "Edit Card")
history.undo()
#expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original,
"byte-for-byte, across every tick the session made")
history.redo()
#expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "One two three.\n")
}
@Test("A session that only read registers nothing")
func anUntouchedSessionRegistersNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body
let session = CardBodyEditSession()
session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished }
session.registerUndo = { [weak store] prior, new in
store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new)
}
session.adopt(diskBody: original)
session.endEditSession()
#expect(history.canUndo == false)
}
@Test("A session typed back to where it started registers nothing")
func aRevertedSessionRegistersNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body
let session = CardBodyEditSession()
session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished }
session.registerUndo = { [weak store] prior, new in
store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new)
}
session.adopt(diskBody: original)
session.edited("Something else.\n")
_ = session.flush()
session.edited(original)
_ = session.flush()
session.endEditSession()
#expect(history.canUndo == false, "the net effect on the file is nothing to undo")
#expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original)
}
}
// MARK: - What registers nothing
@MainActor
@Suite("Undo ▸ the operations that register nothing")
struct NotUndoableTests {
@Test("Delete Immediately registers nothing — the confirm is the safety")
func purgeRegistersNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
let armed = try #require(history.undoActionName)
store.deleteImmediately([trashed])
#expect(fixture.exists(trashedPath) == false)
#expect(store.purgeIsUnrecoverable)
#expect(history.undoActionName == armed, "the stack is exactly where the purge found it")
}
@Test("Empty Trash registers nothing either")
func emptyTrashRegistersNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.emptyTrash()
#expect(fixture.exists(trashedPath) == false)
#expect(history.canUndo == false)
}
@Test("Attachment add and remove register nothing in v1")
func attachmentsRegisterNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let source = try fixture.file("outside/note.txt", Data("hello".utf8))
store.importAttachments([source], toCard: card1)
#expect(fixture.exists("\(card1Path)/attachments") )
#expect(history.canUndo == false)
store.removeAttachment(named: "note.txt", fromCard: card1)
#expect(history.canUndo == false)
}
@Test("A checkbox toggle and a raw-source Apply are outside 13's inventory")
func bodyAdjacentWritesRegisterNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
_ = store.applyCardSource(inCard: card1, text: "---\nschema: 1\norder: 1024\n---\nApplied.\n")
#expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Applied.\n")
#expect(history.canUndo == false)
}
@Test("A store with no stack behind it writes exactly as it always did")
func aStorelessBoardStillWrites() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.delete([card1])
store.setLaneWidth(lane1, units: 2)
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(try document(fixture, Ident.lane1).width.value == 2)
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - The crossing is a write
@MainActor
@Suite("Undo ▸ crossings are ordinary writes")
struct CrossingIsAWriteTests {
@Test("An undo goes through the Writer: it stamps, and it echoes back through the reload")
func undoIsAnAppMediatedWrite() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
history.undo()
let undone = try document(fixture, card1Path)
#expect(undone.title.value == "First")
let stamped = try #require(undone.modified.value)
#expect(stamped.timeIntervalSinceNow > -30, "an inverse is a real write, not an in-memory revert")
#expect(try fixture.indexText(card1Path).contains("modified-by") == false)
// And the board sees it the only way it ever sees anything: through a reload.
await reload(store)
let card = try #require(store.snapshot.lanes.first?.cards.first { $0.id == card1 })
#expect(card.title.value == "First")
}
@Test("Undo, redo, undo — the classic dance over one file")
func theDanceRepeats() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
for _ in 0 ..< 3 {
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing)
history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil)
}
}
}
// MARK: - Staleness
/// A writer that is not the app: `BoardWriter` reached **around** the store, which is exactly what an
/// agent, a hand edit or another editor is from the stack's point of view — a change no step was
/// registered for (13-native-undo.md ▸ Rules: "Foreign writes never join the stack ... collisions are
/// handled lazily, per step, by validation").
private enum Foreign {
static func rename(_ fixture: WriterFixture, _ path: String, to title: String) throws {
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .rename(title: nil)) { document in
document.set(FrontmatterKeys.title, to: .string(title))
}
}
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
document.set(FrontmatterKeys.background, to: .string(background))
}
}
static func setOrder(_ fixture: WriterFixture, _ path: String, to order: Double) throws {
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .reorder(title: nil)) { document in
document.set(FrontmatterKeys.order, to: .double(order))
}
}
static func setBody(_ fixture: WriterFixture, _ path: String, to body: String) throws {
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
}
static func delete(_ fixture: WriterFixture, _ path: String) throws {
try BoardWriter.deleteItem(at: fixture.url(path))
}
static func purge(_ fixture: WriterFixture, _ path: String) throws {
try BoardWriter.purgeItem(at: fixture.url(path))
}
static func move(_ fixture: WriterFixture, _ path: String, toLane lane: String, order: Double) throws {
_ = try BoardWriter.moveItem(
at: fixture.url(path),
toParent: fixture.url(lane),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: order
)
}
}
/// The staleness predicate at ⌘Z time (13-native-undo.md ▸ Rules ▸ staleness validation): "an inverse
/// operation re-checks its target against the current snapshot at ⌘Z time ... Target folder gone, or
/// the field no longer holding the step's after-value → the step is **skipped, not applied**: popped
/// from the stack with an info-tone banner ... and ⌘Z falls through to the next step."
///
/// Every test here is the same hostile shape: perform a gesture, let somebody else write to the board
/// behind the app's back, then press ⌘Z and read the **file**. What must never happen is the inverse
/// landing on top of the foreign write.
@MainActor
@Suite("Undo ▸ staleness")
struct StaleStepTests {
// MARK: Existence and liveness
@Test("A foreign delete of the target skips its step — and ⌘Z falls through to the next one")
func aForeignDeleteSkipsAndFallsThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
store.transient.beginRename(of: card2, currentTitle: "Second")
store.transient.updateRenameDraft("Second!")
store.commitRename()
try Foreign.delete(fixture, card2Path)
history.undo()
// The top step's card is in the trash now, so its rename is not ours to walk back; the one
// below it is untouched and applies in the same ⌘Z.
#expect(try document(fixture, card2Path).title.value == "Second!", "the foreign writer's board, left alone")
#expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something")
#expect(store.banners.signposts.map(\.message)
== ["Undo skipped — 'Second!' changed outside Lanework"])
#expect(history.canUndo == false, "both steps were consumed — one skipped, one applied")
#expect(history.redoActionName == "Rename Card", "only the step that ran is redoable")
}
@Test("A target the foreign writer removed outright skips rather than failing")
func aVanishedTargetSkips() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
try Foreign.purge(fixture, card1Path)
history.undo()
#expect(fixture.exists(card1Path) == false)
#expect(store.banners.signposts.count == 1)
#expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row")
#expect(history.canUndo == false)
#expect(history.canRedo == false, "a skipped step leaves nothing behind")
}
@Test("A foreign Put Back skips the delete's undo — the item is not on the side we left it")
func aForeignRestoreSkipsTheDeleteStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
try BoardWriter.restoreItem(at: fixture.url(card1Path))
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing, "still live, as they left it")
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
}
@Test("A card a foreign writer moved away leaves nothing at the destination to walk back")
func aForeignMoveSkipsTheMoveStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveCards([card1], toLane: lane2, at: 0)
try Foreign.move(fixture, "\(Ident.lane2)/\(Ident.card1)", toLane: Ident.lane1, order: 5000)
history.undo()
#expect(fixture.exists(card1Path), "where the foreign writer put it")
#expect(try document(fixture, card1Path).order.value == 5000, "at the rank they gave it, not ours")
#expect(store.banners.signposts.count == 1)
#expect(history.canUndo == false)
}
@Test("A tombstoned card is stale for a field edit, even with the field itself untouched")
func aTombstonedTargetIsStaleForAFieldEdit() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.setLaneWidth(lane1, units: 3)
try Foreign.delete(fixture, Ident.lane1)
history.undo()
#expect(try document(fixture, Ident.lane1).width.value == 3, "not resized inside the trash")
#expect(store.banners.signposts.count == 1)
}
@Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked")
func anAncestorTombstoneIsStale() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
try Foreign.delete(fixture, Ident.lane1)
history.undo()
#expect(try document(fixture, card1Path).title.value == "Renamed", "the card renders nowhere; nothing was written")
#expect(store.banners.signposts.count == 1)
}
// MARK: The field-level predicate
@Test("A foreign edit of the very field the step wrote skips it")
func aForeignFieldEditSkips() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
try Foreign.rename(fixture, card1Path, to: "Theirs")
history.undo()
#expect(try document(fixture, card1Path).title.value == "Theirs",
"never apply a stale inverse on top of someone else's newer write")
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Renamed' changed outside Lanework"])
#expect(history.canUndo == false)
#expect(history.canRedo == false)
}
@Test("A foreign change to an unrelated item skips nothing — the predicate is per target")
func anUnrelatedForeignChangeAppliesNormally() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
try Foreign.rename(fixture, card2Path, to: "Theirs")
try Foreign.delete(fixture, card3Path)
history.undo()
#expect(try document(fixture, card1Path).title.value == "First", "the step applied")
#expect(try document(fixture, card2Path).title.value == "Theirs", "and left the neighbours alone")
#expect(store.banners.signposts.isEmpty, "nothing to explain")
#expect(history.redoActionName == "Rename Card")
}
@Test("A foreign change to another field of the same item skips nothing either")
func anUnrelatedFieldOfTheSameItemApplies() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
try Foreign.restyle(fixture, card1Path, background: "red")
history.undo()
let undone = try document(fixture, card1Path)
#expect(undone.title.value == "First", "the rename walked back")
#expect(undone.background.value == "red", "their colour survived it")
#expect(store.banners.signposts.isEmpty)
}
@Test("A foreign rank change skips a reorder")
func aForeignRankSkipsAReorder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveLane(lane1, toIndex: 1)
try Foreign.setOrder(fixture, Ident.lane1, to: 9000)
history.undo()
#expect(try document(fixture, Ident.lane1).order.value == 9000)
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Todo' changed outside Lanework"])
}
@Test("A foreign body edit skips the Edit session's step — body steps compare bytes")
func aForeignBodyEditSkipsTheSessionStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body
let session = CardBodyEditSession()
session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished }
session.registerUndo = { [weak store] prior, new in
store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new)
}
session.adopt(diskBody: original)
session.edited("Mine.\n")
_ = session.flush()
session.endEditSession()
#expect(history.undoActionName == "Edit Card")
// One character of difference is a different body: the step wrote every byte of the span.
try Foreign.setBody(fixture, card1Path, to: "Mine.\nAnd theirs.\n")
history.undo()
#expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Mine.\nAnd theirs.\n")
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
}
// MARK: The banner
@Test("A batch names the step, since there is no single item to name")
func aBatchStepNamesItself() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyStyle(to: .items([card1, card2, card3]), background: .set("red"))
try Foreign.restyle(fixture, card2Path, background: "green")
history.undo()
#expect(store.banners.signposts.map(\.message)
== ["Undo skipped — 'Restyle 3 Cards' changed outside Lanework"])
#expect(try document(fixture, card1Path).background.value == "red", "all or nothing: no half-applied batch")
}
@Test("The skip row is an info-tone signpost — dismissable, and never an error")
func theSkipRowIsASignpost() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
try Foreign.purge(fixture, card1Path)
history.undo()
let row = try #require(store.bannerRows.last)
#expect(row.tone == .info)
#expect(row.dismissID != nil, "one-shot lifecycle: the user clears it, nothing expires it")
#expect(store.banners.oneShots.isEmpty)
#expect(store.banners.losses.isEmpty)
}
// MARK: Redo
@Test("Redo validates the same way, and says so in its own verb")
func redoStalenessIsSymmetric() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
history.undo()
#expect(try document(fixture, card1Path).title.value == "First")
// Somebody writes over the state the undo left, so the *forward* write is now the stale one.
try Foreign.rename(fixture, card1Path, to: "Theirs")
history.redo()
#expect(try document(fixture, card1Path).title.value == "Theirs")
#expect(store.banners.signposts.map(\.message)
== ["Redo skipped — 'Renamed' changed outside Lanework"])
#expect(history.canRedo == false)
}
@Test("An undone create redoes only onto the hole it left")
func redoOfACreateChecksTheHole() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let before = try fixture.entryNames("")
store.createLane()
let created = try #require(try fixture.entryNames("").first { !before.contains($0) })
history.undo()
#expect(fixture.exists(created) == false)
// Somebody puts a folder back at that identity — the redo's `recreateItem` refuses to
// clobber, so validation is what turns that into a skip rather than a write failure.
try fixture.item(created, Item.rich(order: "4096", title: "Theirs"))
history.redo()
#expect(try document(fixture, created).title.value == "Theirs")
#expect(store.banners.signposts.count == 1)
#expect(store.banners.oneShots.isEmpty, "skipped before the Writer was ever reached")
}
}
// MARK: - Stale versus failed
/// The distinction 13 leaves to the implementation and this milestone settles: a **stale** step is
/// one the board has moved past — dropped, explained by the info row, ⌘Z falls through. A **failed**
/// one is a step the user still means to cross, refused by a condition that is usually momentary —
/// so it stays put, the ordinary write-failure error row says why, and ⌘Z retries it.
@MainActor
@Suite("Undo ▸ stale versus failed")
struct FailedCrossingTests {
@Test("An inverse that cannot be written keeps its step, and banners as a write failure")
func aFailedInverseKeepsItsStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
// The read and the parse succeed — so validation passes, and the step is genuinely current —
// and then the atomic replace has nowhere to land its temp file.
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
history.undo()
#expect(try document(fixture, card1Path).title.value == "Renamed", "nothing landed")
#expect(store.banners.oneShots.count == 1, "the ordinary write-failure row, not a skip")
#expect(store.banners.signposts.isEmpty)
#expect(history.canUndo, "the step stays: a full disk is not a reason to lose the way back")
#expect(history.undoActionName == "Rename Card")
#expect(history.canRedo == false)
// And when the condition clears, the same ⌘Z works.
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.url(card1Path).path)
history.undo()
#expect(try document(fixture, card1Path).title.value == "First")
#expect(history.canRedo)
}
@Test("A failed crossing stops rather than falling through to the steps below it")
func aFailedCrossingDoesNotFallThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card2])
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
history.undo()
#expect(try document(fixture, card2Path).deleted.value != nil,
"the step below was never reached — a refused disk is not a reason to attempt more")
#expect(history.undoActionName == "Rename Card")
}
}
// MARK: - The read-only lock
/// "Every read-only lock ... disables Undo/Redo with the other mutating commands; the stack itself
/// survives the lock and resumes when it clears" (13-native-undo.md ▸ Rules).
@MainActor
@Suite("Undo ▸ the read-only lock")
struct LockedBoardUndoTests {
@Test("A locked board disables Undo and Redo — and the stack is still there when it clears")
func theLockDisablesAndTheStackSurvives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let manager = BoardUndoManager(history: history, isReadOnly: { [weak store] in store?.isReadOnly ?? false })
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
#expect(manager.canUndo)
store.enterVanishedRootLock()
#expect(manager.canUndo == false, "disabled with every other mutating command")
#expect(manager.canRedo == false)
#expect(history.canUndo, "the stack itself survives the lock")
#expect(manager.undoMenuItemTitle == "Undo Rename Card", "a disabled row keeps its name")
// The lock clears on the next successful reload, and the same ⌘Z crosses the same step.
await reload(store)
#expect(store.isReadOnly == false)
#expect(manager.canUndo)
manager.undo()
#expect(try document(fixture, card1Path).title.value == "First")
}
@Test("A crossing that starts anyway is refused without losing the step")
func aCrossingUnderTheLockLosesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
store.enterVanishedRootLock()
history.undo()
#expect(try document(fixture, card1Path).title.value == "Renamed", "the lock refused the write")
#expect(history.canUndo, "a refusal is a failure, not a staleness — the step stays")
#expect(store.banners.signposts.isEmpty, "the standing lock row is the message")
#expect(store.banners.oneShots.isEmpty, "and a refused write posts nothing of its own")
}
}
// MARK: - The phrase vocabulary
@Suite("Undo ▸ the phrase vocabulary")
struct HistoryPhraseTests {
@Test("Singular and plural follow 06's folding")
func pluralFolding() {
#expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
#expect(HistoryPhrase.name(.move, kind: .card, count: 3) == "Move 3 Cards")
#expect(HistoryPhrase.name(.rename, kind: .lane) == "Rename Lane")
#expect(HistoryPhrase.name(.resize, kind: .lane, count: 2) == "Resize 2 Lanes")
#expect(HistoryPhrase.name(.delete, kind: .card, count: 12) == "Delete 12 Cards")
}
@Test("A count of one or less is the singular, and the board is always singular")
func degenerateCounts() {
#expect(HistoryPhrase.name(.add, kind: .card, count: 1) == "Add Card")
#expect(HistoryPhrase.name(.add, kind: .card, count: 0) == "Add Card")
#expect(HistoryPhrase.name(.restyle, kind: .board, count: 4) == "Restyle Board")
}
@Test("The phrase never spells the verb the platform composes")
func noUndoPrefix() {
for verb in HistoryPhrase.Verb.allCases {
let phrase = HistoryPhrase.name(verb, kind: .card)
#expect(phrase.hasPrefix("Undo") == false)
#expect(phrase.hasPrefix("Redo") == false)
}
}
}
// 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)
}
}