Files
lanework/KanbanTests/UndoWriteTests.swift
T
rzen 2148ebb379 Register inverse operations at the Writer boundary
The store is the Writer boundary, so it computes and registers
inverses: a weak history sink bound at session composition, one
HistoryStep per gesture at exactly the brackets that were already one
performWrite each — multi-card moves, style batches, width pairs, and
multi-row restores each undo as one plurally-titled step, and the Edit
session registers once at the flip from the bytes disk held before its
first landed write, debounce ticks registering nothing. Crossings run
through performWrite, so an undo brackets the watcher, echoes through
the reload, and reaches every window; every closure captures values,
never snapshots. The inventory follows 13 exactly: moves return to
origin lane and order, renames restore or remove the title key,
restyles and resizes restore field values or absence, tombstones and
restores swap with captured timestamps, and an undone create is a real
removal — no trace — with redo re-materializing the same UUID from
bytes captured at gesture time. Purge, attachments, repair,
bookkeeping, checkbox flips, raw Apply, and the whole arrival family
register nothing, each exclusion documented where it lives. Step names
speak 06's verb vocabulary through the new HistoryPhrase.

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

808 lines
30 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: - 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)
}
}
}