Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+151
-108
@@ -18,7 +18,10 @@ import Testing
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
|
||||
/// 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
|
||||
@@ -26,7 +29,6 @@ private func tombstoned(order: String, title: String, deleted: String = "2026-03
|
||||
order: \(order)
|
||||
project: lanework # agent overlay
|
||||
created: 2026-01-01T09:00:00Z
|
||||
deleted: \(deleted)
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
@@ -47,8 +49,8 @@ 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.
|
||||
/// 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()
|
||||
@@ -57,9 +59,9 @@ private func makeBoard() throws -> WriterFixture {
|
||||
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"))
|
||||
try fixture.item(".trash/\(Ident.card4)", trashResident(order: "1024", title: "Trashed"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -74,7 +76,7 @@ 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)"
|
||||
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
|
||||
@@ -488,7 +490,7 @@ struct MoveUndoTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
store.select([card1], liveness: .live)
|
||||
store.select([card1], in: .board)
|
||||
|
||||
store.sortSelection(.down)
|
||||
|
||||
@@ -506,13 +508,13 @@ struct MoveUndoTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash pair
|
||||
// MARK: - Delete
|
||||
|
||||
@MainActor
|
||||
@Suite("Undo ▸ the trash pair")
|
||||
@Suite("Undo ▸ delete")
|
||||
struct TrashUndoTests {
|
||||
|
||||
@Test("Undoing a delete is Put Back — the key goes, and nothing else moves")
|
||||
@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() }
|
||||
@@ -520,20 +522,42 @@ struct TrashUndoTests {
|
||||
let before = try fixture.indexText(card1Path)
|
||||
|
||||
store.delete([card1])
|
||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
||||
#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).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))
|
||||
#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(try document(fixture, card1Path).deleted.value != nil)
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
#expect(!fixture.exists(card1Path))
|
||||
}
|
||||
|
||||
@Test("A multi-item delete is one step with a plural title")
|
||||
@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() }
|
||||
@@ -542,69 +566,74 @@ struct TrashUndoTests {
|
||||
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(try document(fixture, card1Path).deleted.isMissing)
|
||||
#expect(try document(fixture, card2Path).deleted.isMissing)
|
||||
#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 named for the lane")
|
||||
func laneDeleteIsNamedForTheLane() throws {
|
||||
@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(try document(fixture, Ident.lane2).deleted.isMissing)
|
||||
|
||||
#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("Undoing a Put Back re-tombstones with the timestamp the row was filed under")
|
||||
func putBackRoundTrip() throws {
|
||||
@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 filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
|
||||
let trashRank = try #require(try document(fixture, trashedPath).order.value)
|
||||
|
||||
store.putBack([trashed])
|
||||
#expect(try document(fixture, trashedPath).deleted.isMissing)
|
||||
#expect(history.undoActionName == "Restore Card")
|
||||
store.moveCards([trashed], toLane: lane2, at: 0)
|
||||
|
||||
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")
|
||||
#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 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")
|
||||
#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)"))
|
||||
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -703,6 +732,7 @@ struct NotUndoableTests {
|
||||
store.delete([card1])
|
||||
let armed = try #require(history.undoActionName)
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
store.deleteImmediately([trashed])
|
||||
|
||||
#expect(fixture.exists(trashedPath) == false)
|
||||
@@ -758,7 +788,7 @@ struct NotUndoableTests {
|
||||
store.delete([card1])
|
||||
store.setLaneWidth(lane1, units: 2)
|
||||
|
||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
#expect(try document(fixture, Ident.lane1).width.value == 2)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
@@ -803,9 +833,9 @@ struct CrossingIsAWriteTests {
|
||||
store.delete([card1])
|
||||
for _ in 0 ..< 3 {
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
#expect(fixture.exists(card1Path))
|
||||
history.redo()
|
||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -840,8 +870,15 @@ private enum Foreign {
|
||||
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
|
||||
}
|
||||
|
||||
static func delete(_ fixture: WriterFixture, _ path: String) throws {
|
||||
try BoardWriter.deleteItem(at: fixture.url(path))
|
||||
/// 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 {
|
||||
@@ -886,12 +923,13 @@ struct StaleStepTests {
|
||||
store.transient.updateRenameDraft("Second!")
|
||||
store.commitRename()
|
||||
|
||||
try Foreign.delete(fixture, card2Path)
|
||||
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, card2Path).title.value == "Second!", "the foreign writer's board, left alone")
|
||||
#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"])
|
||||
@@ -906,29 +944,33 @@ struct StaleStepTests {
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.delete([card1])
|
||||
try Foreign.purge(fixture, card1Path)
|
||||
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")
|
||||
}
|
||||
|
||||
@Test("A foreign Put Back skips the delete's undo — the item is not on the side we left it")
|
||||
/// 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 BoardWriter.restoreItem(at: fixture.url(card1Path))
|
||||
try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing, "still live, as they left it")
|
||||
#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"])
|
||||
}
|
||||
|
||||
@@ -949,23 +991,25 @@ struct StaleStepTests {
|
||||
#expect(history.canUndo == false)
|
||||
}
|
||||
|
||||
@Test("A tombstoned card is stale for a field edit, even with the field itself untouched")
|
||||
func aTombstonedTargetIsStaleForAFieldEdit() throws {
|
||||
@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.delete(fixture, Ident.lane1)
|
||||
try Foreign.removeLane(fixture, Ident.lane1)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, Ident.lane1).width.value == 3, "not resized inside the trash")
|
||||
#expect(!fixture.exists(Ident.lane1), "not conjured back to be resized")
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
}
|
||||
|
||||
@Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked")
|
||||
func anAncestorTombstoneIsStale() throws {
|
||||
/// 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)
|
||||
@@ -973,11 +1017,11 @@ struct StaleStepTests {
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
try Foreign.delete(fixture, Ident.lane1)
|
||||
try Foreign.removeLane(fixture, Ident.lane1)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "Renamed", "the card renders nowhere; nothing was written")
|
||||
#expect(!fixture.exists(card1Path), "the card is gone; nothing was written")
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
}
|
||||
|
||||
@@ -1014,7 +1058,7 @@ struct StaleStepTests {
|
||||
store.commitRename()
|
||||
|
||||
try Foreign.rename(fixture, card2Path, to: "Theirs")
|
||||
try Foreign.delete(fixture, card3Path)
|
||||
try Foreign.trash(fixture, card3Path, id: Ident.card3)
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "First", "the step applied")
|
||||
@@ -1108,7 +1152,7 @@ struct StaleStepTests {
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.delete([card1])
|
||||
try Foreign.purge(fixture, card1Path)
|
||||
try Foreign.purge(fixture, ".trash/\(Ident.card1)")
|
||||
history.undo()
|
||||
|
||||
let row = try #require(store.bannerRows.last)
|
||||
@@ -1219,7 +1263,7 @@ struct FailedCrossingTests {
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card2Path).deleted.value != nil,
|
||||
#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")
|
||||
}
|
||||
@@ -1322,34 +1366,36 @@ struct HistoryPhraseTests {
|
||||
/// that the two doors reach one state rather than two similar ones, and that alternating between
|
||||
/// them leaves a stack that crosses cleanly in both directions.
|
||||
@MainActor
|
||||
@Suite("Undo ▸ the trash's two doors")
|
||||
@Suite("Undo ▸ the trash and the stack are the same folder moves")
|
||||
struct TrashInterplayTests {
|
||||
|
||||
@Test("Undoing a delete lands exactly where Put Back would have — the same bytes, not a near miss")
|
||||
/// 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 byPutBack = try makeBoard()
|
||||
defer { byPutBack.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 (putBackStore, _) = try makeStore(byPutBack)
|
||||
putBackStore.delete([card1])
|
||||
// Put Back reads the trashed side of the snapshot, so it has to see the tombstone first —
|
||||
// which is the one-way flow, not a test artefact.
|
||||
await reload(putBackStore)
|
||||
putBackStore.putBack([card1])
|
||||
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(byPutBack), "the two doors")
|
||||
expectSameBoard(try boardTexts(byUndo), try boardTexts(byMove), "the two doors")
|
||||
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
|
||||
#expect(try document(byUndo, card1Path).deleted.isMissing)
|
||||
#expect(try document(byPutBack, card1Path).deleted.isMissing)
|
||||
#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")
|
||||
@@ -1358,25 +1404,25 @@ struct TrashInterplayTests {
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
/// The lane's live cards in display order, read through the loader — the order the board
|
||||
/// actually renders, rather than the ranks it is derived from.
|
||||
func liveCards() throws -> [String] {
|
||||
/// 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.filter { !$0.isDeleted }.map(\.id.rawValue)
|
||||
return lane.cards.map(\.id.rawValue)
|
||||
}
|
||||
let before = try liveCards()
|
||||
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 liveCards() == [Ident.card1, Ident.card3])
|
||||
#expect(try laneCards() == [Ident.card1, Ident.card3])
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try liveCards() == before, "01's deletion bullet: restore is position-perfect")
|
||||
#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")
|
||||
}
|
||||
|
||||
@@ -1387,30 +1433,27 @@ struct TrashInterplayTests {
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let live = try boardTexts(fixture)
|
||||
|
||||
// Three gestures over one card, alternating the doors: ⌫, Put Back, ⌫.
|
||||
// Three gestures over one card, alternating the doors: ⌫, move back out, ⌫.
|
||||
store.delete([card1])
|
||||
await reload(store)
|
||||
let filedUnder = try #require(try document(fixture, card1Path).deleted.value)
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
|
||||
store.putBack([card1])
|
||||
store.moveCards([card1], toLane: lane1, at: 0)
|
||||
await reload(store)
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
#expect(fixture.exists(card1Path))
|
||||
|
||||
store.delete([card1])
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Delete Card")
|
||||
let trashed = try boardTexts(fixture)
|
||||
let trashedBoard = try boardTexts(fixture)
|
||||
|
||||
// Back up the stack: delete → restore → tombstone → restore. The middle step is Put Back's
|
||||
// inverse, which has to re-file the row under the stamp it was filed under rather than under
|
||||
// now — the trash sorts by it.
|
||||
// Back up the stack: delete → move → delete, each crossed in reverse.
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
#expect(history.undoActionName == "Restore Card")
|
||||
#expect(fixture.exists(card1Path))
|
||||
#expect(history.undoActionName == "Move Card")
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).deleted.value == filedUnder,
|
||||
"re-tombstoned where it was filed, not where a fresh stamp would put it")
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of")
|
||||
#expect(history.undoActionName == "Delete Card")
|
||||
|
||||
history.undo()
|
||||
@@ -1419,12 +1462,12 @@ struct TrashInterplayTests {
|
||||
|
||||
// And forward again, the same three steps in the other direction.
|
||||
history.redo()
|
||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
history.redo()
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
#expect(fixture.exists(card1Path))
|
||||
history.redo()
|
||||
#expect(history.canRedo == false)
|
||||
expectSameBoard(try boardTexts(fixture), trashed, "three doors forward")
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user