import Foundation import Testing @testable import Kanban /// The inverses registered at the Writer boundary (13-native-undo.md ▸ Rules) — one round trip per /// operation: perform the gesture, cross it backwards, read the **bytes on disk**, cross it forwards /// again. /// /// These drive a real store over a real temp board with a real `NativeHistoryProvider` behind it, and /// assert against the files rather than the snapshot, like every other write suite here. That is the /// only way to check the claim 13 actually makes: an undo is "an ordinary app-mediated write", not an /// in-memory revert — so what has to come back is the *file*. /// /// What "equals prior" means differs by operation, exactly as the design does: **byte-level for a /// body** (the body span is all a body write touches) and **field-level for frontmatter** (`modified` /// is stamped by every app write, undo included, so a byte comparison would be asserting the opposite /// of the storage contract). `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Fixtures /// A card sitting in `/.trash/` — an ordinary card in a special place (03-board-ui.md § /// Trash), with the unknown-key overlay every other fixture card carries so an inverse's /// verbatim-preservation claim has something to preserve. private func trashResident(order: String, title: String) -> String { """ --- schema: 1 title: \(title) order: \(order) project: lanework # agent overlay created: 2026-01-01T09:00:00Z --- \(title) body. """ } private let styledCard = """ --- schema: 1 title: Styled order: 3072 project: lanework # agent overlay background: blue icon: star created: 2026-01-01T09:00:00Z --- Styled body. """ /// Two lanes — the first with two plain cards and a styled one, the second with one card — plus one /// card already sitting in the board's `.trash/`. Enough for every inverse in this file. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere")) try fixture.item(".trash/\(Ident.card4)", trashResident(order: "1024", title: "Trashed")) return fixture } private let lane1 = ItemID(rawValue: Ident.lane1) private let lane2 = ItemID(rawValue: Ident.lane2) private let card1 = ItemID(rawValue: Ident.card1) private let card2 = ItemID(rawValue: Ident.card2) private let card3 = ItemID(rawValue: Ident.card3) private let trashed = ItemID(rawValue: Ident.card4) private let elsewhere = ItemID(rawValue: Ident.indexless) private let card1Path = "\(Ident.lane1)/\(Ident.card1)" private let card2Path = "\(Ident.lane1)/\(Ident.card2)" private let card3Path = "\(Ident.lane1)/\(Ident.card3)" private let trashedPath = ".trash/\(Ident.card4)" /// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak** /// — the session owns the stack in the app, and a test that dropped it would watch its own steps /// disappear. @MainActor private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) { let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history return (store, history) } @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() } /// One item's frontmatter as the app reads it — the level "equals prior" is asserted at for /// everything but a body. private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument { try FrontmatterDocument.parse(fixture.indexText(relativePath)) } /// The file's lines minus the ones every app-mediated write owns — what an inverse must leave /// byte-identical, unknown keys and their comments included. private func untouchedLines(_ text: String) -> [Substring] { text.split(separator: "\n", omittingEmptySubsequences: false).filter { // `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one, so a // round trip lands the same bytes *plus* the backfilled key, which is not the undo's doing. !$0.hasPrefix("modified") && !$0.hasPrefix("kind:") } } /// Every `index.md` on the board, keyed by its folder's path relative to the root — the whole of /// what a round trip has to land back on, files-are-truth read literally. /// /// Read as raw bytes off disk rather than through the loader, like every other assertion in this /// file: the board being *equivalent* is not the claim, the board being the same bytes is. private func boardTexts(_ fixture: WriterFixture) throws -> [String: String] { var texts: [String: String] = [:] let rootDepth = fixture.root.standardizedFileURL.pathComponents.count guard let walker = FileManager.default.enumerator(at: fixture.root, includingPropertiesForKeys: nil) else { return texts } for case let url as URL in walker where url.lastPathComponent == "index.md" { let folder = url.deletingLastPathComponent().standardizedFileURL let relative = folder.pathComponents.dropFirst(rootDepth).joined(separator: "/") texts[relative] = try String(decoding: Data(contentsOf: url), as: UTF8.self) } return texts } /// Asserts two whole-board readings are the same board — the same items, and every file identical /// but for the stamps every app write owns. /// /// **"Byte-identical" is byte-identical-except-`modified`**, and deliberately so: an inverse is an /// ordinary app-mediated write (13-native-undo.md), so it stamps on the way past, and 13 promises /// state equivalence rather than mtime equality. That is exactly the precision /// `WriteFidelityTombstoneTests.deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp` /// established for the delete→restore round trip, applied here to a whole board. private func expectSameBoard(_ actual: [String: String], _ expected: [String: String], _ label: String) { #expect(actual.keys.sorted() == expected.keys.sorted(), "\(label): the board holds different items") for (path, text) in expected { guard let landed = actual[path] else { continue } #expect(untouchedLines(landed) == untouchedLines(text), "\(label): '\(path)' came back different") } } // MARK: - Resize @MainActor @Suite("Undo ▸ resize") struct ResizeUndoTests { @Test("A width change undoes to the prior width and redoes to the new one") func widthRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.setLaneWidth(lane1, units: 3) #expect(try document(fixture, Ident.lane1).width.value == 3) #expect(history.undoActionName == "Resize Lane") history.undo() // The lane had no `width` key at all, so "prior" is its absence — not `width: 1`. #expect(try document(fixture, Ident.lane1).width.isMissing) history.redo() #expect(try document(fixture, Ident.lane1).width.value == 3) } @Test("An explicit prior width comes back as the value it was, not as the app's default") func priorValueIsRestoredVerbatim() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.setLaneWidth(lane1, units: 4) // The second gesture reads its prior value off the snapshot, so it has to see the first — // which is the one-way flow working, not a test artefact. await reload(store) store.setLaneWidth(lane1, units: 2) #expect(try document(fixture, Ident.lane1).width.value == 2) history.undo() #expect(try document(fixture, Ident.lane1).width.value == 4) } @Test("A multi-lane step is one step with a plural title") func batchIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.stepLaneWidths([lane1, lane2], by: 1) #expect(history.undoActionName == "Resize 2 Lanes") #expect(try document(fixture, Ident.lane1).width.value == 2) #expect(try document(fixture, Ident.lane2).width.value == 2) history.undo() #expect(try document(fixture, Ident.lane1).width.isMissing) #expect(try document(fixture, Ident.lane2).width.isMissing) #expect(history.canUndo == false, "one gesture, one step") } } // MARK: - Restyle @MainActor @Suite("Undo ▸ restyle") struct RestyleUndoTests { @Test("A style change undoes to the prior values — removing the keys that were not there") func styleRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.indexText(card1Path) store.applyStyle(to: .items([card1]), background: .set("red"), icon: .set("flag")) #expect(try document(fixture, card1Path).background.value == "red") #expect(history.undoActionName == "Restyle Card") history.undo() let undone = try document(fixture, card1Path) #expect(undone.background.isMissing) #expect(undone.icon.isMissing) // Nothing but the styled keys and the stamp moved — the unknown key with its comment, the // reserved `labels`, `created`, and the body all came back through untouched. #expect(untouchedLines(try fixture.indexText(card1Path)) == untouchedLines(before)) history.redo() #expect(try document(fixture, card1Path).background.value == "red") #expect(try document(fixture, card1Path).icon.value == "flag") } @Test("A prior value is restored as itself, not removed") func priorStyleValueComesBack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .items([card3]), background: .set("green")) history.undo() let undone = try document(fixture, card3Path) #expect(undone.background.value == "blue") #expect(undone.icon.value == "star", "a dimension the gesture did not touch is not touched back") } @Test("A styling batch is one step, with a plural title") func batchIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .items([card1, card2, card3]), background: .set("red")) #expect(history.undoActionName == "Restyle 3 Cards") history.undo() #expect(try document(fixture, card1Path).background.isMissing) #expect(try document(fixture, card2Path).background.isMissing) #expect(try document(fixture, card3Path).background.value == "blue") #expect(history.canUndo == false) } @Test("The board's own styling names the board") func boardStyleIsNamedForTheBoard() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .board, background: .set("graphite")) #expect(history.undoActionName == "Restyle Board") history.undo() #expect(try document(fixture, "").background.isMissing) } } // MARK: - Rename @MainActor @Suite("Undo ▸ rename") struct RenameUndoTests { @Test("A card rename undoes to the prior title and redoes to the new one") func renameRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() #expect(try document(fixture, card1Path).title.value == "Renamed") #expect(history.undoActionName == "Rename Card") history.undo() #expect(try document(fixture, card1Path).title.value == "First") history.redo() #expect(try document(fixture, card1Path).title.value == "Renamed") } @Test("An emptied title undoes back to the title that was there") func emptyRenameRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft(" ") store.commitRename() #expect(try document(fixture, card1Path).title.isMissing, "an empty commit removes the key") history.undo() #expect(try document(fixture, card1Path).title.value == "First") history.redo() #expect(try document(fixture, card1Path).title.isMissing) } @Test("A lane rename is named for the lane; the board's for the board") func namesFollowTheLevel() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: lane1, currentTitle: "Todo") store.transient.updateRenameDraft("Backlog") store.commitRename() #expect(history.undoActionName == "Rename Lane") store.renameBoard("Project") #expect(history.undoActionName == "Rename Board") #expect(try document(fixture, "").title.value == "Project") history.undo() #expect(try document(fixture, "").title.value == "Board") history.undo() #expect(try document(fixture, Ident.lane1).title.value == "Todo") } @Test("An unchanged rename writes nothing and registers nothing") func aNoOpRenameRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("First") store.commitRename() #expect(history.canUndo == false) } } // MARK: - Create @MainActor @Suite("Undo ▸ create") struct CreateUndoTests { @Test("Undoing a lane create removes the folder; redo puts it back, identity and bytes intact") func laneCreateRoundTrip() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.entryNames("") store.createLane() let created = try #require(try fixture.entryNames("").first { !before.contains($0) }) let bytes = try fixture.indexData(created) #expect(history.undoActionName == "Add Lane") history.undo() #expect(fixture.exists(created) == false, "an undone create leaves no trace — not a tombstone") history.redo() #expect(fixture.exists(created), "the same UUID, so every later step still names something") #expect(try fixture.indexData(created) == bytes, "replayed verbatim — nothing re-serialized") } @Test("Undoing a card create removes the folder, rank and all") func cardCreateRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.entryNames(Ident.lane1) store.transient.beginPlaceholder(inLane: lane1, after: card1) store.transient.updateDraft("Fresh") let created = try #require(store.commitPlaceholder()) let path = "\(Ident.lane1)/\(created.rawValue)" let bytes = try fixture.indexData(path) #expect(history.undoActionName == "Add Card") #expect(try fixture.entryNames(Ident.lane1).count == before.count + 1) history.undo() #expect(fixture.exists(path) == false) history.redo() #expect(try fixture.indexData(path) == bytes, "the rank it was placed at rides in its bytes") } } // MARK: - Move and reorder @MainActor @Suite("Undo ▸ move and reorder") struct MoveUndoTests { @Test("A cross-lane move undoes to the original lane at the original order") func moveRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1], toLane: lane2, at: 0) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)")) #expect(fixture.exists(card1Path) == false) #expect(history.undoActionName == "Move Card") history.undo() #expect(fixture.exists(card1Path), "the folder moved back") #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)") == false) #expect(try document(fixture, card1Path).order.value == 1024, "at the rank it left") history.redo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)")) } @Test("A multi-card move is one step with a plural title") func multiCardMoveIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1, card2, card3], toLane: lane2, at: 0) #expect(history.undoActionName == "Move 3 Cards") history.undo() #expect(fixture.exists(card1Path)) #expect(fixture.exists(card2Path)) #expect(fixture.exists(card3Path)) #expect(try document(fixture, card1Path).order.value == 1024) #expect(try document(fixture, card2Path).order.value == 2048) #expect(try document(fixture, card3Path).order.value == 3072) #expect(history.canUndo == false, "one gesture, one step") } @Test("A drop that stays in its own lane is a Reorder, and undoes to the rank it held") func sameLaneDropIsAReorder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1], toLane: lane1, at: 2) #expect(history.undoActionName == "Reorder Card") #expect(try document(fixture, card1Path).order.value != 1024) history.undo() #expect(try document(fixture, card1Path).order.value == 1024) } @Test("A lane drag undoes to the lane's own prior rank") func laneReorderRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveLane(lane1, toIndex: 1) let moved = try #require(try document(fixture, Ident.lane1).order.value) #expect(moved > 2048) #expect(history.undoActionName == "Reorder Lane") history.undo() #expect(try document(fixture, Ident.lane1).order.value == 1024) history.redo() #expect(try document(fixture, Ident.lane1).order.value == moved) } /// **The inverses conform to the container-change predicate** (01-storage-format.md /// § Frontmatter ▸ `modified`'s scope, refined 2026-07-30) — the m8 conformance check, stated at /// the level the rule is about: an inverse is an ordinary app-mediated write, so it is subject to /// the *same* predicate as the gesture it inverts, not to a rule of its own. /// /// Three claims in one round trip, because they are one claim: the undo of a within-lane reorder is /// itself a within-lane reorder and rewrites only `order`; the undo of a cross-lane move is itself a /// cross-lane move and stamps; and **no trash-specific branch exists in either direction** — the /// trash round trip stamps for the same reason the cross-lane one does. /// /// It reads `modified-by` rather than `modified`, deliberately: `untouchedLines` filters the whole /// `modified*` family precisely because a content write is *expected* to move it, so the foreign /// stamp's survival is the assertion with a sharp edge — it survives an order-only rewrite and is /// cleared by a content one, and `Item.rich` plants one on every fixture card for exactly this. @Test("An inverse stamps only when it changes a container") func inversesFollowTheContainerPredicate() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) // Within-lane, there and back: nothing on either leg is a content write. store.moveCards([card1], toLane: lane1, at: 2) #expect(try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude") history.undo() #expect( try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude", "undoing a reorder is a reorder — order-only, both ways" ) // Cross-lane, there and back: both legs change the container, so both stamp. store.moveCards([card2], toLane: lane2, at: 0) #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card2)").rawValue(for: FrontmatterKeys.modifiedBy) == nil) // Re-planted by hand, standing in for an agent that stamped the card in its new lane — the // inverse has to clear it again, because moving back is itself a container change. try BoardWriter.updateIndex( inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card2)"), operation: .style(title: nil) ) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) } history.undo() #expect( try document(fixture, card2Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil, "undoing a cross-lane move is a cross-lane move — it stamps" ) } /// The lane half of the same claim: a lane's container is the board root and never changes, so a /// lane drag and its inverse are both order-only. @Test("A lane reorder and its inverse are both order-only") func laneInversesAreOrderOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveLane(lane1, toIndex: 1) #expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude") history.undo() #expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude") history.redo() #expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude") } /// The trash round trip, from the undo stack rather than the Writer: the delete stamps and its /// inverse — the move back out — stamps too. **Neither is a special case**; both are container /// changes, which is the whole of the refinement. @Test("A delete and its inverse both stamp, with no trash branch") func theTrashRoundTripStampsBothWays() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.select([card1], in: .board) store.deleteSelection() #expect(fixture.exists(".trash/\(Ident.card1)")) #expect(try document(fixture, ".trash/\(Ident.card1)").rawValue(for: FrontmatterKeys.modifiedBy) == nil) try BoardWriter.updateIndex( inItemFolder: fixture.url(".trash/\(Ident.card1)"), operation: .style(title: nil) ) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) } history.undo() #expect(fixture.exists(card1Path)) #expect( try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil, "restoring out of the trash is a container change and clears the stamp" ) } @Test("⌥⌘↓ undoes the whole permutation, siblings included") func sortRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.select([card1], in: .board) store.sortSelection(.down) #expect(history.undoActionName == "Reorder Card") #expect(try document(fixture, card1Path).order.value == 2048) #expect(try document(fixture, card2Path).order.value == 1024) history.undo() #expect(try document(fixture, card1Path).order.value == 1024) #expect(try document(fixture, card2Path).order.value == 2048) history.redo() #expect(try document(fixture, card1Path).order.value == 2048) #expect(try document(fixture, card2Path).order.value == 1024) } } // MARK: - Delete @MainActor @Suite("Undo ▸ delete") struct TrashUndoTests { @Test("Undoing a card delete moves it back out of the trash, to its lane and its rank") func deleteRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.indexText(card1Path) store.delete([card1]) #expect(!fixture.exists(card1Path), "the folder physically left its lane") #expect(fixture.exists(".trash/\(Ident.card1)")) #expect(history.undoActionName == "Delete Card") history.undo() #expect(fixture.exists(card1Path), "13 ▸ Interaction with the trash: the undo is the move back") #expect(!fixture.exists(".trash/\(Ident.card1)")) let undone = try fixture.indexText(card1Path) #expect(try FrontmatterDocument.parse(undone).order.value == 1024, "at its original rank") // Byte-identical but for the stamps every app write owns — no `deleted:` key was ever // written, so there is none to come back and none to remove. #expect(untouchedLines(undone) == untouchedLines(before)) history.redo() #expect(fixture.exists(".trash/\(Ident.card1)")) #expect(!fixture.exists(card1Path)) } @Test("The redo files the card under the rank the delete minted, not a fresh one") func redoUsesTheCapturedTrashRank() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) #expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)") history.undo() history.redo() #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted, "the redo replays the write's own captured rank") } @Test("A multi-card delete is one step with a plural title, and lands newest-last on top") func batchDeleteIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1, card2]) #expect(history.undoActionName == "Delete 2 Cards") let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) #expect(second < first, "each arrival in the run mints a rank above the one before it") history.undo() #expect(fixture.exists(card1Path)) #expect(fixture.exists(card2Path)) #expect(try loadedTrash(fixture).map(\.id) == [trashed], "only the board's own resident is left") #expect(history.canUndo == false) } /// 13 ▸ Interaction with the trash: "a lane [returns] to its strip position (subtree intact — it /// never left the folder)". The inverse is the ordinary move back, so the capture/recreate /// machinery is retired: the lane's bytes never left the disk to need replaying. @Test("A lane delete is a move into the trash, and its undo is the move back to its strip rank") func laneDeleteRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let laneBefore = try fixture.indexText(Ident.lane2) let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") let laneRank = try #require(try document(fixture, Ident.lane2).order.value) store.delete([lane2]) #expect(!fixture.exists(Ident.lane2)) #expect(fixture.exists(".trash/\(Ident.lane2)")) #expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards") #expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2]) #expect(history.undoActionName == "Delete Lane") let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) let resident = try #require(try document(fixture, trashedPath).order.value) #expect(trashRank < resident, "entry is at the top — a rank above the current topmost") history.undo() #expect(fixture.exists(Ident.lane2)) #expect(!fixture.exists(".trash/\(Ident.lane2)")) #expect(try document(fixture, Ident.lane2).order.value == laneRank, "at its own strip position") #expect(untouchedLines(try fixture.indexText(Ident.lane2)) == untouchedLines(laneBefore), "byte-identical but for the stamps every container-changing move owns") #expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText, "the subtree never moved relative to its lane, so not one nested byte changed") history.redo() #expect(fixture.exists(".trash/\(Ident.lane2)")) #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank, "the redo replays the write's own captured rank") } @Test("A multi-lane delete is one step with a plural title") func batchLaneDeleteIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([lane1, lane2]) #expect(history.undoActionName == "Delete 2 Lanes") let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value) let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) #expect(second < first, "each arrival in the run mints a rank above the one before it") history.undo() #expect(fixture.exists(Ident.lane1)) #expect(fixture.exists(Ident.lane2)) #expect(try loadedTrashedLanes(fixture).isEmpty) #expect(history.canUndo == false) } @Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse") func restoreIsAnOrdinaryMove() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let trashRank = try #require(try document(fixture, trashedPath).order.value) store.moveCards([trashed], toLane: lane2, at: 0) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(!fixture.exists(trashedPath)) #expect(history.undoActionName == "Move Card", "13: a restore is an ordinary move between containers, named as one") history.undo() #expect(fixture.exists(trashedPath), "back in the trash it came out of") #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(try document(fixture, trashedPath).order.value == trashRank, "at the rank it was filed under") history.redo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) } } /// The board's trash as the loader reads it — never the store's snapshot, which a write deliberately /// does not touch. @MainActor private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] { try BoardLoader.load(boardRoot: fixture.root).model.trash } @MainActor private func loadedTrashedLanes(_ fixture: WriterFixture) throws -> [TrashedLane] { try BoardLoader.load(boardRoot: fixture.root).model.trashedLanes } // MARK: - The Edit session @MainActor @Suite("Undo ▸ the Edit session") struct BodyUndoTests { @Test("A session's saves are one step, registered at the flip, with the body it started from") func sessionIsOneStep() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) // Three debounced ticks inside one session — none of them a step. session.edited("One.\n") _ = session.flush() session.edited("One two.\n") _ = session.flush() session.edited("One two three.\n") _ = session.flush() #expect(history.canUndo == false, "a save tick is not a step") session.endEditSession() #expect(history.undoActionName == "Edit Card") history.undo() #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original, "byte-for-byte, across every tick the session made") history.redo() #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "One two three.\n") } @Test("A session that only read registers nothing") func anUntouchedSessionRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) session.endEditSession() #expect(history.canUndo == false) } @Test("A session typed back to where it started registers nothing") func aRevertedSessionRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) session.edited("Something else.\n") _ = session.flush() session.edited(original) _ = session.flush() session.endEditSession() #expect(history.canUndo == false, "the net effect on the file is nothing to undo") #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original) } } // MARK: - What registers nothing @MainActor @Suite("Undo ▸ the operations that register nothing") struct NotUndoableTests { @Test("The trash's permanent delete registers nothing — the confirm is the safety") func purgeRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) let armed = try #require(history.undoActionName) store.select([trashed], in: .trash) store.deleteTrashEntries([trashed]) #expect(fixture.exists(trashedPath) == false) #expect(store.purgeIsUnrecoverable) #expect(history.undoActionName == armed, "the stack is exactly where the purge found it") } @Test("Empty Trash registers nothing either") func emptyTrashRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.emptyTrash() #expect(fixture.exists(trashedPath) == false) #expect(history.canUndo == false) } @Test("Attachment add and remove register nothing in v1") func attachmentsRegisterNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let source = try fixture.file("outside/note.txt", Data("hello".utf8)) store.importAttachments([source], toCard: card1) #expect(fixture.exists("\(card1Path)/attachments") ) #expect(history.canUndo == false) store.removeAttachment(named: "note.txt", fromCard: card1) #expect(history.canUndo == false) } @Test("A checkbox toggle and a raw-source Apply are outside 13's inventory") func bodyAdjacentWritesRegisterNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) _ = store.applyCardSource(inCard: card1, text: "---\nschema: 1\norder: 1024\n---\nApplied.\n") #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Applied.\n") #expect(history.canUndo == false) } @Test("A store with no stack behind it writes exactly as it always did") func aStorelessBoardStillWrites() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.delete([card1]) store.setLaneWidth(lane1, units: 2) #expect(fixture.exists(".trash/\(Ident.card1)")) #expect(try document(fixture, Ident.lane1).width.value == 2) #expect(store.banners.oneShots.isEmpty) } } // MARK: - The crossing is a write @MainActor @Suite("Undo ▸ crossings are ordinary writes") struct CrossingIsAWriteTests { @Test("An undo goes through the Writer: it stamps, and it echoes back through the reload") func undoIsAnAppMediatedWrite() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() history.undo() let undone = try document(fixture, card1Path) #expect(undone.title.value == "First") let stamped = try #require(undone.modified.value) #expect(stamped.timeIntervalSinceNow > -30, "an inverse is a real write, not an in-memory revert") #expect(try fixture.indexText(card1Path).contains("modified-by") == false) // And the board sees it the only way it ever sees anything: through a reload. await reload(store) let card = try #require(store.snapshot.lanes.first?.cards.first { $0.id == card1 }) #expect(card.title.value == "First") } @Test("Undo, redo, undo — the classic dance over one file") func theDanceRepeats() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) for _ in 0 ..< 3 { history.undo() #expect(fixture.exists(card1Path)) history.redo() #expect(fixture.exists(".trash/\(Ident.card1)")) } } } // MARK: - Staleness /// A writer that is not the app: `BoardWriter` reached **around** the store, which is exactly what an /// agent, a hand edit or another editor is from the stack's point of view — a change no step was /// registered for (13-native-undo.md ▸ Rules: "Foreign writes never join the stack ... collisions are /// handled lazily, per step, by validation"). private enum Foreign { static func rename(_ fixture: WriterFixture, _ path: String, to title: String) throws { try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .rename(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string(title)) } } static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws { try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in document.set(FrontmatterKeys.background, to: .string(background)) } } static func setOrder(_ fixture: WriterFixture, _ path: String, to order: Double) throws { try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .reorder(title: nil)) { document in document.set(FrontmatterKeys.order, to: .double(order)) } } static func setBody(_ fixture: WriterFixture, _ path: String, to body: String) throws { _ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body) } /// A foreign delete of a **card** — the shape a delete has on disk now: the folder moves into /// `.trash/`. `FileManager` and nothing else, so no rank is minted and no stamp is written. static func trash(_ fixture: WriterFixture, _ path: String, id: String) throws { try fixture.move(path, toTrash: id) } /// A **Finder deletion** of a lane: the folder disappears entirely, which "is also a delete" /// (01 § Deletion) — such items never enter the trash, so this is the shape a foreign writer's /// destructive removal has, distinct from the app's own delete (a move into `.trash/`). static func removeLane(_ fixture: WriterFixture, _ path: String) throws { try FileManager.default.removeItem(at: fixture.url(path)) } static func purge(_ fixture: WriterFixture, _ path: String) throws { try BoardWriter.purgeItem(at: fixture.url(path)) } static func move(_ fixture: WriterFixture, _ path: String, toLane lane: String, order: Double) throws { _ = try BoardWriter.moveItem( at: fixture.url(path), toParent: fixture.url(lane), sourceBoardRoot: fixture.root, destinationBoardRoot: fixture.root, order: order ) } } /// The staleness predicate at ⌘Z time (13-native-undo.md ▸ Rules ▸ staleness validation): "an inverse /// operation re-checks its target against the current snapshot at ⌘Z time ... Target folder gone, or /// the field no longer holding the step's after-value → the step is **skipped, not applied**: popped /// from the stack with an info-tone banner ... and ⌘Z falls through to the next step." /// /// Every test here is the same hostile shape: perform a gesture, let somebody else write to the board /// behind the app's back, then press ⌘Z and read the **file**. What must never happen is the inverse /// landing on top of the foreign write. @MainActor @Suite("Undo ▸ staleness") struct StaleStepTests { // MARK: Existence and liveness @Test("A foreign delete of the target skips its step — and ⌘Z falls through to the next one") func aForeignDeleteSkipsAndFallsThrough() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() store.transient.beginRename(of: card2, currentTitle: "Second") store.transient.updateRenameDraft("Second!") store.commitRename() try Foreign.trash(fixture, card2Path, id: Ident.card2) history.undo() // The top step's card is in the trash now, so its rename is not ours to walk back; the one // below it is untouched and applies in the same ⌘Z. #expect(try document(fixture, ".trash/\(Ident.card2)").title.value == "Second!", "the foreign writer's board, left alone") #expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Second!' changed outside Lanework"]) #expect(history.canUndo == false, "both steps were consumed — one skipped, one applied") #expect(history.redoActionName == "Rename Card", "only the step that ran is redoable") } @Test("A target the foreign writer removed outright skips rather than failing") func aVanishedTargetSkips() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) try Foreign.purge(fixture, ".trash/\(Ident.card1)") history.undo() #expect(fixture.exists(card1Path) == false) #expect(!fixture.exists(".trash/\(Ident.card1)")) #expect(store.banners.signposts.count == 1) #expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row") #expect(history.canUndo == false) #expect(history.canRedo == false, "a skipped step leaves nothing behind") } /// The container check, and it needs no field of its own: a delete step's undo expects its card /// at `/.trash/`, and a foreign restore leaves that path empty (`HistoryStaleness`). @Test("A foreign restore skips the delete's undo — the card is not in the container we left it") func aForeignRestoreSkipsTheDeleteStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1) history.undo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"), "where the foreign writer put it") #expect(!fixture.exists(card1Path), "and not moved back on top of them") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"]) } @Test("A card a foreign writer moved away leaves nothing at the destination to walk back") func aForeignMoveSkipsTheMoveStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1], toLane: lane2, at: 0) try Foreign.move(fixture, "\(Ident.lane2)/\(Ident.card1)", toLane: Ident.lane1, order: 5000) history.undo() #expect(fixture.exists(card1Path), "where the foreign writer put it") #expect(try document(fixture, card1Path).order.value == 5000, "at the rank they gave it, not ours") #expect(store.banners.signposts.count == 1) #expect(history.canUndo == false) } @Test("A foreign lane delete is stale for a field edit — there is nothing at the path") func aRemovedLaneIsStaleForAFieldEdit() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.setLaneWidth(lane1, units: 3) try Foreign.removeLane(fixture, Ident.lane1) history.undo() #expect(!fixture.exists(Ident.lane1), "not conjured back to be resized") #expect(store.banners.signposts.count == 1) } /// The card's own path is the check: its lane's folder is gone, so the card is simply not there /// any more — no ancestor walk, which is what materializing the trash bought. (The same holds /// when the app trashes the lane: the card's path moves under `.trash/` and nothing is left at /// the one the step named.) @Test("A card whose lane a foreign writer deleted is stale too") func aCardUnderARemovedLaneIsStale() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() try Foreign.removeLane(fixture, Ident.lane1) history.undo() #expect(!fixture.exists(card1Path), "the card is gone; nothing was written") #expect(store.banners.signposts.count == 1) } // MARK: The field-level predicate @Test("A foreign edit of the very field the step wrote skips it") func aForeignFieldEditSkips() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() try Foreign.rename(fixture, card1Path, to: "Theirs") history.undo() #expect(try document(fixture, card1Path).title.value == "Theirs", "never apply a stale inverse on top of someone else's newer write") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Renamed' changed outside Lanework"]) #expect(history.canUndo == false) #expect(history.canRedo == false) } @Test("A foreign change to an unrelated item skips nothing — the predicate is per target") func anUnrelatedForeignChangeAppliesNormally() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() try Foreign.rename(fixture, card2Path, to: "Theirs") try Foreign.trash(fixture, card3Path, id: Ident.card3) history.undo() #expect(try document(fixture, card1Path).title.value == "First", "the step applied") #expect(try document(fixture, card2Path).title.value == "Theirs", "and left the neighbours alone") #expect(store.banners.signposts.isEmpty, "nothing to explain") #expect(history.redoActionName == "Rename Card") } @Test("A foreign change to another field of the same item skips nothing either") func anUnrelatedFieldOfTheSameItemApplies() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() try Foreign.restyle(fixture, card1Path, background: "red") history.undo() let undone = try document(fixture, card1Path) #expect(undone.title.value == "First", "the rename walked back") #expect(undone.background.value == "red", "their colour survived it") #expect(store.banners.signposts.isEmpty) } @Test("A foreign rank change skips a reorder") func aForeignRankSkipsAReorder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveLane(lane1, toIndex: 1) try Foreign.setOrder(fixture, Ident.lane1, to: 9000) history.undo() #expect(try document(fixture, Ident.lane1).order.value == 9000) #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Todo' changed outside Lanework"]) } @Test("A foreign body edit skips the Edit session's step — body steps compare bytes") func aForeignBodyEditSkipsTheSessionStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) session.edited("Mine.\n") _ = session.flush() session.endEditSession() #expect(history.undoActionName == "Edit Card") // One character of difference is a different body: the step wrote every byte of the span. try Foreign.setBody(fixture, card1Path, to: "Mine.\nAnd theirs.\n") history.undo() #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Mine.\nAnd theirs.\n") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"]) } // MARK: The banner @Test("A batch names the step, since there is no single item to name") func aBatchStepNamesItself() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .items([card1, card2, card3]), background: .set("red")) try Foreign.restyle(fixture, card2Path, background: "green") history.undo() #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Restyle 3 Cards' changed outside Lanework"]) #expect(try document(fixture, card1Path).background.value == "red", "all or nothing: no half-applied batch") } @Test("The skip row is an info-tone signpost — dismissable, and never an error") func theSkipRowIsASignpost() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) try Foreign.purge(fixture, ".trash/\(Ident.card1)") history.undo() let row = try #require(store.bannerRows.last) #expect(row.tone == .info) #expect(row.dismissID != nil, "one-shot lifecycle: the user clears it, nothing expires it") #expect(store.banners.oneShots.isEmpty) #expect(store.banners.losses.isEmpty) } // MARK: Redo @Test("Redo validates the same way, and says so in its own verb") func redoStalenessIsSymmetric() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() history.undo() #expect(try document(fixture, card1Path).title.value == "First") // Somebody writes over the state the undo left, so the *forward* write is now the stale one. try Foreign.rename(fixture, card1Path, to: "Theirs") history.redo() #expect(try document(fixture, card1Path).title.value == "Theirs") #expect(store.banners.signposts.map(\.message) == ["Redo skipped — 'Renamed' changed outside Lanework"]) #expect(history.canRedo == false) } @Test("An undone create redoes only onto the hole it left") func redoOfACreateChecksTheHole() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.entryNames("") store.createLane() let created = try #require(try fixture.entryNames("").first { !before.contains($0) }) history.undo() #expect(fixture.exists(created) == false) // Somebody puts a folder back at that identity — the redo's `recreateItem` refuses to // clobber, so validation is what turns that into a skip rather than a write failure. try fixture.item(created, Item.rich(order: "4096", title: "Theirs")) history.redo() #expect(try document(fixture, created).title.value == "Theirs") #expect(store.banners.signposts.count == 1) #expect(store.banners.oneShots.isEmpty, "skipped before the Writer was ever reached") } } // MARK: - Stale versus failed /// The distinction 13 leaves to the implementation and this milestone settles: a **stale** step is /// one the board has moved past — dropped, explained by the info row, ⌘Z falls through. A **failed** /// one is a step the user still means to cross, refused by a condition that is usually momentary — /// so it stays put, the ordinary write-failure error row says why, and ⌘Z retries it. @MainActor @Suite("Undo ▸ stale versus failed") struct FailedCrossingTests { @Test("An inverse that cannot be written keeps its step, and banners as a write failure") func aFailedInverseKeepsItsStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() // The read and the parse succeed — so validation passes, and the step is genuinely current — // and then the atomic replace has nowhere to land its temp file. try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path) history.undo() #expect(try document(fixture, card1Path).title.value == "Renamed", "nothing landed") #expect(store.banners.oneShots.count == 1, "the ordinary write-failure row, not a skip") #expect(store.banners.signposts.isEmpty) #expect(history.canUndo, "the step stays: a full disk is not a reason to lose the way back") #expect(history.undoActionName == "Rename Card") #expect(history.canRedo == false) // And when the condition clears, the same ⌘Z works. try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.url(card1Path).path) history.undo() #expect(try document(fixture, card1Path).title.value == "First") #expect(history.canRedo) } @Test("A failed crossing stops rather than falling through to the steps below it") func aFailedCrossingDoesNotFallThrough() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card2]) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path) history.undo() #expect(fixture.exists(".trash/\(Ident.card2)"), "the step below was never reached — a refused disk is not a reason to attempt more") #expect(history.undoActionName == "Rename Card") } } // MARK: - The read-only lock /// "Every read-only lock ... disables Undo/Redo with the other mutating commands; the stack itself /// survives the lock and resumes when it clears" (13-native-undo.md ▸ Rules). @MainActor @Suite("Undo ▸ the read-only lock") struct LockedBoardUndoTests { @Test("A locked board disables Undo and Redo — and the stack is still there when it clears") func theLockDisablesAndTheStackSurvives() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let manager = BoardUndoManager(history: history, isReadOnly: { [weak store] in store?.isReadOnly ?? false }) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() #expect(manager.canUndo) store.enterVanishedRootLock() #expect(manager.canUndo == false, "disabled with every other mutating command") #expect(manager.canRedo == false) #expect(history.canUndo, "the stack itself survives the lock") #expect(manager.undoMenuItemTitle == "Undo Rename Card", "a disabled row keeps its name") // The lock clears on the next successful reload, and the same ⌘Z crosses the same step. await reload(store) #expect(store.isReadOnly == false) #expect(manager.canUndo) manager.undo() #expect(try document(fixture, card1Path).title.value == "First") } @Test("A crossing that starts anyway is refused without losing the step") func aCrossingUnderTheLockLosesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() store.enterVanishedRootLock() history.undo() #expect(try document(fixture, card1Path).title.value == "Renamed", "the lock refused the write") #expect(history.canUndo, "a refusal is a failure, not a staleness — the step stays") #expect(store.banners.signposts.isEmpty, "the standing lock row is the message") #expect(store.banners.oneShots.isEmpty, "and a refused write posts nothing of its own") } } // MARK: - The phrase vocabulary @Suite("Undo ▸ the phrase vocabulary") struct HistoryPhraseTests { @Test("Singular and plural follow 06's folding") func pluralFolding() { #expect(HistoryPhrase.name(.move, kind: .card) == "Move Card") #expect(HistoryPhrase.name(.move, kind: .card, count: 3) == "Move 3 Cards") #expect(HistoryPhrase.name(.rename, kind: .lane) == "Rename Lane") #expect(HistoryPhrase.name(.resize, kind: .lane, count: 2) == "Resize 2 Lanes") #expect(HistoryPhrase.name(.delete, kind: .card, count: 12) == "Delete 12 Cards") } @Test("A count of one or less is the singular, and the board is always singular") func degenerateCounts() { #expect(HistoryPhrase.name(.add, kind: .card, count: 1) == "Add Card") #expect(HistoryPhrase.name(.add, kind: .card, count: 0) == "Add Card") #expect(HistoryPhrase.name(.restyle, kind: .board, count: 4) == "Restyle Board") } @Test("The phrase never spells the verb the platform composes") func noUndoPrefix() { for verb in HistoryPhrase.Verb.allCases { let phrase = HistoryPhrase.name(verb, kind: .card) #expect(phrase.hasPrefix("Undo") == false) #expect(phrase.hasPrefix("Redo") == false) } } } // MARK: - The trash's two doors /// 13-native-undo.md ▸ Interaction with the trash: "⌫'s undo is the move back ... a restore-by-move /// undoes the same way in reverse. **The stack and the trash never conflict** — they are the same /// folder moves addressed by recency instead of by selection." /// /// The per-operation round trips live in `TrashUndoTests` above. What is here is the *interplay*: /// that the two doors reach one state rather than two similar ones, and that alternating between /// them leaves a stack that crosses cleanly in both directions. @MainActor @Suite("Undo ▸ the trash and the stack are the same folder moves") struct TrashInterplayTests { /// 13-native-undo.md ▸ Interaction with the trash: "The stack and the trash never conflict — they /// are the same folder moves addressed by recency instead of by selection." @Test("Undoing a delete lands exactly where a manual move-out would — the same bytes") func theTwoDoorsReachOneState() async throws { // Two identical boards, one per door: the claim is about a *state*, so the honest comparison // is the whole board read off disk, not the one field each path happens to write. let byUndo = try makeBoard() defer { byUndo.tearDown() } let byMove = try makeBoard() defer { byMove.tearDown() } let origin = try boardTexts(byUndo) let (undoStore, history) = try makeStore(byUndo) undoStore.delete([card1]) history.undo() let (moveStore, _) = try makeStore(byMove) moveStore.delete([card1]) // The move-out reads the trash side of the snapshot, so it has to see the card arrive there // first — which is the one-way flow, not a test artefact. await reload(moveStore) moveStore.moveCards([card1], toLane: lane1, at: 0) expectSameBoard(try boardTexts(byUndo), try boardTexts(byMove), "the two doors") expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from") #expect(byUndo.exists(card1Path)) #expect(byMove.exists(card1Path)) } @Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end") func undoRestoresInPlace() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) /// The lane's cards in display order, read through the loader — the order the board actually /// renders, rather than the ranks it is derived from. func laneCards() throws -> [String] { let result = try BoardLoader.load(boardRoot: fixture.root) let lane = try #require(result.model.lanes.first { $0.id == lane1 }) return lane.cards.map(\.id.rawValue) } let before = try laneCards() #expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle") // The middle of three: an implementation that restored by appending would pass on a first or // last card and fail here. store.delete([card2]) await reload(store) #expect(try laneCards() == [Ident.card1, Ident.card3]) history.undo() #expect(try laneCards() == before, "13: the undo returns the card to its source lane and rank") #expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along") } @Test("Alternating the two doors leaves a stack that crosses cleanly, both ways") func interleavedDoorsCrossBothWays() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let live = try boardTexts(fixture) // Three gestures over one card, alternating the doors: ⌫, move back out, ⌫. store.delete([card1]) await reload(store) #expect(fixture.exists(".trash/\(Ident.card1)")) store.moveCards([card1], toLane: lane1, at: 0) await reload(store) #expect(fixture.exists(card1Path)) store.delete([card1]) await reload(store) #expect(history.undoActionName == "Delete Card") let trashedBoard = try boardTexts(fixture) // Back up the stack: delete → move → delete, each crossed in reverse. history.undo() #expect(fixture.exists(card1Path)) #expect(history.undoActionName == "Move Card") history.undo() #expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of") #expect(history.undoActionName == "Delete Card") history.undo() #expect(history.canUndo == false) expectSameBoard(try boardTexts(fixture), live, "three doors back") // And forward again, the same three steps in the other direction. history.redo() #expect(fixture.exists(".trash/\(Ident.card1)")) history.redo() #expect(fixture.exists(card1Path)) history.redo() #expect(history.canRedo == false) expectSameBoard(try boardTexts(fixture), trashedBoard, "three doors forward") #expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided") #expect(store.banners.oneShots.isEmpty) } } // MARK: - The capstone /// **A whole session, unwound and wound back up.** 13-native-undo.md's promise, taken literally: a /// user creates, moves, styles, renames and deletes without touching the mouse, then holds ⌘Z until /// the board is the one they opened — and ⇧⌘Z back to the one they built. /// /// The assertion is the *board*, read off disk in full: every `index.md`, byte for byte but for the /// `modified` stamps every app write owns (`expectSameBoard`, whose note records why that is the /// right precision — an inverse is an ordinary write, and 13 promises state equivalence rather than /// mtime equality). /// /// Five gestures rather than one of each kind on purpose: they all name the **same card**, so each /// step's staleness predicate is validated against a board four other steps have since written to. /// A chain that only worked on untouched targets would pass a suite of five isolated round trips and /// fail here. @MainActor @Suite("Undo ▸ the full session") struct FullSessionUndoTests { @Test("Create, move, style, rename, delete — then ⌘Z back to the origin and ⇧⌘Z forward again") func theWholeSessionRoundTrips() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let origin = try boardTexts(fixture) // 1 — create. The one gesture whose undo removes a folder rather than rewriting one. store.transient.beginPlaceholder(inLane: lane1, after: card1) store.transient.updateDraft("Fresh") let created = try #require(store.commitPlaceholder()) await reload(store) #expect(history.undoActionName == "Add Card") // 2 — move it to the other lane. store.moveCards([created], toLane: lane2, at: 0) await reload(store) #expect(history.undoActionName == "Move Card") // 3 — style it. store.applyStyle(to: .items([created]), background: .set("red"), icon: .set("flag")) await reload(store) #expect(history.undoActionName == "Restyle Card") // 4 — rename it. store.transient.beginRename(of: created, currentTitle: "Fresh") store.transient.updateRenameDraft("Renamed") store.commitRename() await reload(store) #expect(history.undoActionName == "Rename Card") // 5 — delete it. store.delete([created]) await reload(store) #expect(history.undoActionName == "Delete Card") let built = try boardTexts(fixture) #expect(built.count == origin.count + 1, "one new card, and nothing else arrived") // ⌘Z ×5. The Edit menu's row renames itself on every press — the platform composes // "Undo " from exactly these names (`BoardUndoManager`). for next in ["Rename Card", "Restyle Card", "Move Card", "Add Card"] { history.undo() #expect(history.undoActionName == next) } history.undo() #expect(history.canUndo == false, "five gestures, five steps, no more and no fewer") #expect(history.redoActionName == "Add Card") expectSameBoard(try boardTexts(fixture), origin, "unwound") #expect(store.banners.signposts.isEmpty, "nothing went stale under its own session") #expect(store.banners.oneShots.isEmpty, "and nothing failed to write") // ⇧⌘Z ×5, back to the board the session built. for next in ["Move Card", "Restyle Card", "Rename Card", "Delete Card"] { history.redo() #expect(history.redoActionName == next) } history.redo() #expect(history.canRedo == false) #expect(history.undoActionName == "Delete Card") expectSameBoard(try boardTexts(fixture), built, "rewound") #expect(store.banners.signposts.isEmpty) #expect(store.banners.oneShots.isEmpty) } }