Files
lanework/KanbanTests/UndoWriteTests.swift
T
rzen 50669489cb Implement staleness validation and skip-with-banner
Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 14:54:42 -04:00

1279 lines
50 KiB
Swift

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")
}
}
// 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)
}
}
}