Files
lanework/KanbanTests/UndoWriteTests.swift
T
rzen 785ef5fe14 Realign code with the 2026-07-30 findings-resolution rulings
Delete Immediately is removed entirely (ruling ae1dd96, Redesign card
d40bfac1): the delete vocabulary is purely staged — board → trash,
trash → permanent (confirmed on no-git boards), Empty Trash for bulk.
Gone: File ▸ Delete Immediately (⌥⌘⌫) and its validation, both
⌥-alternate context rows (card + the permanently-disabled lane row),
the VO custom action, BoardStore.deleteImmediately,
TrashModel.canDeleteImmediately, TrashConfirmations' .purge action
(zero surviving callers — trash-side Delete always used
.deleteTrashCards), and the pinning tests. purgePrompt drops its
now-single-purpose container parameter (.trash is the only surviving
caller). BoardWriter.purgeItem survives — create-undo rollback still
needs it — with its comment rewritten. README's trash paragraph drops
the ⌥⌘⌫ sentence.

The other 2026-07-30 rulings (2ec2c95 registry freshness stamp,
c741b02 unified-log-as-coerce-consumer) required no code changes —
already conformant.

Both schemes 1844 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 15:13:31 -04:00

1649 lines
69 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
/// A card sitting in `<root>/.trash/` — an ordinary card in a special place (03-board-ui.md §
/// Trash), with the unknown-key overlay every other fixture card carries so an inverse's
/// verbatim-preservation claim has something to preserve.
private func trashResident(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
---
\(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 lanes — the first with two plain cards and a styled one, the second with one card — plus one
/// card already sitting in the board's `.trash/`. 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.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere"))
try fixture.item(".trash/\(Ident.card4)", trashResident(order: "1024", title: "Trashed"))
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 = ".trash/\(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 {
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one, so a
// round trip lands the same bytes *plus* the backfilled key, which is not the undo's doing.
!$0.hasPrefix("modified") && !$0.hasPrefix("kind:")
}
}
/// 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)
}
/// **The inverses conform to the container-change predicate** (01-storage-format.md
/// § Frontmatter ▸ `modified`'s scope, refined 2026-07-30) — the m8 conformance check, stated at
/// the level the rule is about: an inverse is an ordinary app-mediated write, so it is subject to
/// the *same* predicate as the gesture it inverts, not to a rule of its own.
///
/// Three claims in one round trip, because they are one claim: the undo of a within-lane reorder is
/// itself a within-lane reorder and rewrites only `order`; the undo of a cross-lane move is itself a
/// cross-lane move and stamps; and **no trash-specific branch exists in either direction** — the
/// trash round trip stamps for the same reason the cross-lane one does.
///
/// It reads `modified-by` rather than `modified`, deliberately: `untouchedLines` filters the whole
/// `modified*` family precisely because a content write is *expected* to move it, so the foreign
/// stamp's survival is the assertion with a sharp edge — it survives an order-only rewrite and is
/// cleared by a content one, and `Item.rich` plants one on every fixture card for exactly this.
@Test("An inverse stamps only when it changes a container")
func inversesFollowTheContainerPredicate() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
// Within-lane, there and back: nothing on either leg is a content write.
store.moveCards([card1], toLane: lane1, at: 2)
#expect(try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
history.undo()
#expect(
try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude",
"undoing a reorder is a reorder — order-only, both ways"
)
// Cross-lane, there and back: both legs change the container, so both stamp.
store.moveCards([card2], toLane: lane2, at: 0)
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card2)").rawValue(for: FrontmatterKeys.modifiedBy) == nil)
// Re-planted by hand, standing in for an agent that stamped the card in its new lane — the
// inverse has to clear it again, because moving back is itself a container change.
try BoardWriter.updateIndex(
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card2)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
history.undo()
#expect(
try document(fixture, card2Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil,
"undoing a cross-lane move is a cross-lane move — it stamps"
)
}
/// The lane half of the same claim: a lane's container is the board root and never changes, so a
/// lane drag and its inverse are both order-only.
@Test("A lane reorder and its inverse are both order-only")
func laneInversesAreOrderOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveLane(lane1, toIndex: 1)
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
history.undo()
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
history.redo()
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
}
/// The trash round trip, from the undo stack rather than the Writer: the delete stamps and its
/// inverse — the move back out — stamps too. **Neither is a special case**; both are container
/// changes, which is the whole of the refinement.
@Test("A delete and its inverse both stamp, with no trash branch")
func theTrashRoundTripStampsBothWays() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.select([card1], in: .board)
store.deleteSelection()
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(try document(fixture, ".trash/\(Ident.card1)").rawValue(for: FrontmatterKeys.modifiedBy) == nil)
try BoardWriter.updateIndex(
inItemFolder: fixture.url(".trash/\(Ident.card1)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
history.undo()
#expect(fixture.exists(card1Path))
#expect(
try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil,
"restoring out of the trash is a container change and clears the stamp"
)
}
@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], in: .board)
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: - Delete
@MainActor
@Suite("Undo ▸ delete")
struct TrashUndoTests {
@Test("Undoing a card delete moves it back out of the trash, to its lane and its rank")
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(!fixture.exists(card1Path), "the folder physically left its lane")
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(history.undoActionName == "Delete Card")
history.undo()
#expect(fixture.exists(card1Path), "13 ▸ Interaction with the trash: the undo is the move back")
#expect(!fixture.exists(".trash/\(Ident.card1)"))
let undone = try fixture.indexText(card1Path)
#expect(try FrontmatterDocument.parse(undone).order.value == 1024, "at its original rank")
// Byte-identical but for the stamps every app write owns — no `deleted:` key was ever
// written, so there is none to come back and none to remove.
#expect(untouchedLines(undone) == untouchedLines(before))
history.redo()
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(!fixture.exists(card1Path))
}
@Test("The redo files the card under the rank the delete minted, not a fresh one")
func redoUsesTheCapturedTrashRank() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
#expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)")
history.undo()
history.redo()
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted,
"the redo replays the write's own captured rank")
}
@Test("A multi-card delete is one step with a plural title, and lands newest-last on top")
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")
let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value)
#expect(second < first, "each arrival in the run mints a rank above the one before it")
history.undo()
#expect(fixture.exists(card1Path))
#expect(fixture.exists(card2Path))
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "only the board's own resident is left")
#expect(history.canUndo == false)
}
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
func laneDeleteRecreatesTheSubtree() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let laneText = try fixture.indexText(Ident.lane2)
let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
store.delete([lane2])
#expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically")
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed")
#expect(history.undoActionName == "Delete Lane")
history.undo()
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit")
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText,
"and the whole subtree comes back with it — nested cards included")
history.redo()
#expect(!fixture.exists(Ident.lane2))
}
@Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
func restoreIsAnOrdinaryMove() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let trashRank = try #require(try document(fixture, trashedPath).order.value)
store.moveCards([trashed], toLane: lane2, at: 0)
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(!fixture.exists(trashedPath))
#expect(history.undoActionName == "Move Card",
"13: a restore is an ordinary move between containers, named as one")
history.undo()
#expect(fixture.exists(trashedPath), "back in the trash it came out of")
#expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, trashedPath).order.value == trashRank,
"at the rank it was filed under")
history.redo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
}
}
/// The board's trash as the loader reads it — never the store's snapshot, which a write deliberately
/// does not touch.
@MainActor
private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] {
try BoardLoader.load(boardRoot: fixture.root).model.trash
}
// 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("The trash's permanent delete 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.select([trashed], in: .trash)
store.deleteTrashCards([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(fixture.exists(".trash/\(Ident.card1)"))
#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(fixture.exists(card1Path))
history.redo()
#expect(fixture.exists(".trash/\(Ident.card1)"))
}
}
}
// 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)
}
/// A foreign delete of a **card** — the shape a delete has on disk now: the folder moves into
/// `.trash/`. `FileManager` and nothing else, so no rank is minted and no stamp is written.
static func trash(_ fixture: WriterFixture, _ path: String, id: String) throws {
try fixture.move(path, toTrash: id)
}
/// A foreign delete of a **lane** — physical, since lanes are never trashed.
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
try FileManager.default.removeItem(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.trash(fixture, card2Path, id: Ident.card2)
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, ".trash/\(Ident.card2)").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, ".trash/\(Ident.card1)")
history.undo()
#expect(fixture.exists(card1Path) == false)
#expect(!fixture.exists(".trash/\(Ident.card1)"))
#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")
}
/// The container check, and it needs no field of its own: a delete step's undo expects its card
/// at `<root>/.trash/<id>`, and a foreign restore leaves that path empty (`HistoryStaleness`).
@Test("A foreign restore skips the delete's undo — the card is not in the container we left it")
func aForeignRestoreSkipsTheDeleteStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1)
history.undo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"), "where the foreign writer put it")
#expect(!fixture.exists(card1Path), "and not moved back on top of them")
#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 foreign lane delete is stale for a field edit — there is nothing at the path")
func aRemovedLaneIsStaleForAFieldEdit() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.setLaneWidth(lane1, units: 3)
try Foreign.removeLane(fixture, Ident.lane1)
history.undo()
#expect(!fixture.exists(Ident.lane1), "not conjured back to be resized")
#expect(store.banners.signposts.count == 1)
}
/// The card's own path is the check: a lane delete is physical, so a card under it is simply not
/// there any more — no ancestor walk, which is what materializing the trash bought.
@Test("A card whose lane a foreign writer deleted is stale too")
func aCardUnderARemovedLaneIsStale() 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.removeLane(fixture, Ident.lane1)
history.undo()
#expect(!fixture.exists(card1Path), "the card is gone; 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.trash(fixture, card3Path, id: Ident.card3)
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, ".trash/\(Ident.card1)")
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(fixture.exists(".trash/\(Ident.card2)"),
"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 and the stack are the same folder moves")
struct TrashInterplayTests {
/// 13-native-undo.md ▸ Interaction with the trash: "The stack and the trash never conflict — they
/// are the same folder moves addressed by recency instead of by selection."
@Test("Undoing a delete lands exactly where a manual move-out would — the same bytes")
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 byMove = try makeBoard()
defer { byMove.tearDown() }
let origin = try boardTexts(byUndo)
let (undoStore, history) = try makeStore(byUndo)
undoStore.delete([card1])
history.undo()
let (moveStore, _) = try makeStore(byMove)
moveStore.delete([card1])
// The move-out reads the trash side of the snapshot, so it has to see the card arrive there
// first — which is the one-way flow, not a test artefact.
await reload(moveStore)
moveStore.moveCards([card1], toLane: lane1, at: 0)
expectSameBoard(try boardTexts(byUndo), try boardTexts(byMove), "the two doors")
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
#expect(byUndo.exists(card1Path))
#expect(byMove.exists(card1Path))
}
@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 cards in display order, read through the loader — the order the board actually
/// renders, rather than the ranks it is derived from.
func laneCards() throws -> [String] {
let result = try BoardLoader.load(boardRoot: fixture.root)
let lane = try #require(result.model.lanes.first { $0.id == lane1 })
return lane.cards.map(\.id.rawValue)
}
let before = try laneCards()
#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 laneCards() == [Ident.card1, Ident.card3])
history.undo()
#expect(try laneCards() == before, "13: the undo returns the card to its source lane and rank")
#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: ⌫, move back out, ⌫.
store.delete([card1])
await reload(store)
#expect(fixture.exists(".trash/\(Ident.card1)"))
store.moveCards([card1], toLane: lane1, at: 0)
await reload(store)
#expect(fixture.exists(card1Path))
store.delete([card1])
await reload(store)
#expect(history.undoActionName == "Delete Card")
let trashedBoard = try boardTexts(fixture)
// Back up the stack: delete → move → delete, each crossed in reverse.
history.undo()
#expect(fixture.exists(card1Path))
#expect(history.undoActionName == "Move Card")
history.undo()
#expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of")
#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(fixture.exists(".trash/\(Ident.card1)"))
history.redo()
#expect(fixture.exists(card1Path))
history.redo()
#expect(history.canRedo == false)
expectSameBoard(try boardTexts(fixture), trashedBoard, "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)
}
}