import Foundation import Testing @testable import Kanban /// The inverses registered at the Writer boundary (13-native-undo.md ▸ Rules) — one round trip per /// operation: perform the gesture, cross it backwards, read the **bytes on disk**, cross it forwards /// again. /// /// These drive a real store over a real temp board with a real `NativeHistoryProvider` behind it, and /// assert against the files rather than the snapshot, like every other write suite here. That is the /// only way to check the claim 13 actually makes: an undo is "an ordinary app-mediated write", not an /// in-memory revert — so what has to come back is the *file*. /// /// What "equals prior" means differs by operation, exactly as the design does: **byte-level for a /// body** (the body span is all a body write touches) and **field-level for frontmatter** (`modified` /// is stamped by every app write, undo included, so a byte comparison would be asserting the opposite /// of the storage contract). `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Fixtures private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { """ --- schema: 1 title: \(title) order: \(order) project: lanework # agent overlay created: 2026-01-01T09:00:00Z deleted: \(deleted) --- \(title) body. """ } private let styledCard = """ --- schema: 1 title: Styled order: 3072 project: lanework # agent overlay background: blue icon: star created: 2026-01-01T09:00:00Z --- Styled body. """ /// Two live lanes — the first with two live cards, a styled one and a tombstoned one; the second /// with one card. Enough for every inverse in this file. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard) try fixture.item("\(Ident.lane1)/\(Ident.card4)", tombstoned(order: "4096", title: "Trashed")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere")) return fixture } private let lane1 = ItemID(rawValue: Ident.lane1) private let lane2 = ItemID(rawValue: Ident.lane2) private let card1 = ItemID(rawValue: Ident.card1) private let card2 = ItemID(rawValue: Ident.card2) private let card3 = ItemID(rawValue: Ident.card3) private let trashed = ItemID(rawValue: Ident.card4) private let elsewhere = ItemID(rawValue: Ident.indexless) private let card1Path = "\(Ident.lane1)/\(Ident.card1)" private let card2Path = "\(Ident.lane1)/\(Ident.card2)" private let card3Path = "\(Ident.lane1)/\(Ident.card3)" private let trashedPath = "\(Ident.lane1)/\(Ident.card4)" /// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak** /// — the session owns the stack in the app, and a test that dropped it would watch its own steps /// disappear. @MainActor private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) { let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history return (store, history) } @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() } /// One item's frontmatter as the app reads it — the level "equals prior" is asserted at for /// everything but a body. private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument { try FrontmatterDocument.parse(fixture.indexText(relativePath)) } /// The file's lines minus the ones every app-mediated write owns — what an inverse must leave /// byte-identical, unknown keys and their comments included. private func untouchedLines(_ text: String) -> [Substring] { text.split(separator: "\n", omittingEmptySubsequences: false).filter { !$0.hasPrefix("modified") } } // MARK: - Resize @MainActor @Suite("Undo ▸ resize") struct ResizeUndoTests { @Test("A width change undoes to the prior width and redoes to the new one") func widthRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.setLaneWidth(lane1, units: 3) #expect(try document(fixture, Ident.lane1).width.value == 3) #expect(history.undoActionName == "Resize Lane") history.undo() // The lane had no `width` key at all, so "prior" is its absence — not `width: 1`. #expect(try document(fixture, Ident.lane1).width.isMissing) history.redo() #expect(try document(fixture, Ident.lane1).width.value == 3) } @Test("An explicit prior width comes back as the value it was, not as the app's default") func priorValueIsRestoredVerbatim() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.setLaneWidth(lane1, units: 4) // The second gesture reads its prior value off the snapshot, so it has to see the first — // which is the one-way flow working, not a test artefact. await reload(store) store.setLaneWidth(lane1, units: 2) #expect(try document(fixture, Ident.lane1).width.value == 2) history.undo() #expect(try document(fixture, Ident.lane1).width.value == 4) } @Test("A multi-lane step is one step with a plural title") func batchIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.stepLaneWidths([lane1, lane2], by: 1) #expect(history.undoActionName == "Resize 2 Lanes") #expect(try document(fixture, Ident.lane1).width.value == 2) #expect(try document(fixture, Ident.lane2).width.value == 2) history.undo() #expect(try document(fixture, Ident.lane1).width.isMissing) #expect(try document(fixture, Ident.lane2).width.isMissing) #expect(history.canUndo == false, "one gesture, one step") } } // MARK: - Restyle @MainActor @Suite("Undo ▸ restyle") struct RestyleUndoTests { @Test("A style change undoes to the prior values — removing the keys that were not there") func styleRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.indexText(card1Path) store.applyStyle(to: .items([card1]), background: .set("red"), icon: .set("flag")) #expect(try document(fixture, card1Path).background.value == "red") #expect(history.undoActionName == "Restyle Card") history.undo() let undone = try document(fixture, card1Path) #expect(undone.background.isMissing) #expect(undone.icon.isMissing) // Nothing but the styled keys and the stamp moved — the unknown key with its comment, the // reserved `labels`, `created`, and the body all came back through untouched. #expect(untouchedLines(try fixture.indexText(card1Path)) == untouchedLines(before)) history.redo() #expect(try document(fixture, card1Path).background.value == "red") #expect(try document(fixture, card1Path).icon.value == "flag") } @Test("A prior value is restored as itself, not removed") func priorStyleValueComesBack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .items([card3]), background: .set("green")) history.undo() let undone = try document(fixture, card3Path) #expect(undone.background.value == "blue") #expect(undone.icon.value == "star", "a dimension the gesture did not touch is not touched back") } @Test("A styling batch is one step, with a plural title") func batchIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .items([card1, card2, card3]), background: .set("red")) #expect(history.undoActionName == "Restyle 3 Cards") history.undo() #expect(try document(fixture, card1Path).background.isMissing) #expect(try document(fixture, card2Path).background.isMissing) #expect(try document(fixture, card3Path).background.value == "blue") #expect(history.canUndo == false) } @Test("The board's own styling names the board") func boardStyleIsNamedForTheBoard() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyStyle(to: .board, background: .set("graphite")) #expect(history.undoActionName == "Restyle Board") history.undo() #expect(try document(fixture, "").background.isMissing) } } // MARK: - Rename @MainActor @Suite("Undo ▸ rename") struct RenameUndoTests { @Test("A card rename undoes to the prior title and redoes to the new one") func renameRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() #expect(try document(fixture, card1Path).title.value == "Renamed") #expect(history.undoActionName == "Rename Card") history.undo() #expect(try document(fixture, card1Path).title.value == "First") history.redo() #expect(try document(fixture, card1Path).title.value == "Renamed") } @Test("An emptied title undoes back to the title that was there") func emptyRenameRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft(" ") store.commitRename() #expect(try document(fixture, card1Path).title.isMissing, "an empty commit removes the key") history.undo() #expect(try document(fixture, card1Path).title.value == "First") history.redo() #expect(try document(fixture, card1Path).title.isMissing) } @Test("A lane rename is named for the lane; the board's for the board") func namesFollowTheLevel() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: lane1, currentTitle: "Todo") store.transient.updateRenameDraft("Backlog") store.commitRename() #expect(history.undoActionName == "Rename Lane") store.renameBoard("Project") #expect(history.undoActionName == "Rename Board") #expect(try document(fixture, "").title.value == "Project") history.undo() #expect(try document(fixture, "").title.value == "Board") history.undo() #expect(try document(fixture, Ident.lane1).title.value == "Todo") } @Test("An unchanged rename writes nothing and registers nothing") func aNoOpRenameRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("First") store.commitRename() #expect(history.canUndo == false) } } // MARK: - Create @MainActor @Suite("Undo ▸ create") struct CreateUndoTests { @Test("Undoing a lane create removes the folder; redo puts it back, identity and bytes intact") func laneCreateRoundTrip() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.entryNames("") store.createLane() let created = try #require(try fixture.entryNames("").first { !before.contains($0) }) let bytes = try fixture.indexData(created) #expect(history.undoActionName == "Add Lane") history.undo() #expect(fixture.exists(created) == false, "an undone create leaves no trace — not a tombstone") history.redo() #expect(fixture.exists(created), "the same UUID, so every later step still names something") #expect(try fixture.indexData(created) == bytes, "replayed verbatim — nothing re-serialized") } @Test("Undoing a card create removes the folder, rank and all") func cardCreateRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.entryNames(Ident.lane1) store.transient.beginPlaceholder(inLane: lane1, after: card1) store.transient.updateDraft("Fresh") let created = try #require(store.commitPlaceholder()) let path = "\(Ident.lane1)/\(created.rawValue)" let bytes = try fixture.indexData(path) #expect(history.undoActionName == "Add Card") #expect(try fixture.entryNames(Ident.lane1).count == before.count + 1) history.undo() #expect(fixture.exists(path) == false) history.redo() #expect(try fixture.indexData(path) == bytes, "the rank it was placed at rides in its bytes") } } // MARK: - Move and reorder @MainActor @Suite("Undo ▸ move and reorder") struct MoveUndoTests { @Test("A cross-lane move undoes to the original lane at the original order") func moveRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1], toLane: lane2, at: 0) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)")) #expect(fixture.exists(card1Path) == false) #expect(history.undoActionName == "Move Card") history.undo() #expect(fixture.exists(card1Path), "the folder moved back") #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)") == false) #expect(try document(fixture, card1Path).order.value == 1024, "at the rank it left") history.redo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)")) } @Test("A multi-card move is one step with a plural title") func multiCardMoveIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1, card2, card3], toLane: lane2, at: 0) #expect(history.undoActionName == "Move 3 Cards") history.undo() #expect(fixture.exists(card1Path)) #expect(fixture.exists(card2Path)) #expect(fixture.exists(card3Path)) #expect(try document(fixture, card1Path).order.value == 1024) #expect(try document(fixture, card2Path).order.value == 2048) #expect(try document(fixture, card3Path).order.value == 3072) #expect(history.canUndo == false, "one gesture, one step") } @Test("A drop that stays in its own lane is a Reorder, and undoes to the rank it held") func sameLaneDropIsAReorder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveCards([card1], toLane: lane1, at: 2) #expect(history.undoActionName == "Reorder Card") #expect(try document(fixture, card1Path).order.value != 1024) history.undo() #expect(try document(fixture, card1Path).order.value == 1024) } @Test("A lane drag undoes to the lane's own prior rank") func laneReorderRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.moveLane(lane1, toIndex: 1) let moved = try #require(try document(fixture, Ident.lane1).order.value) #expect(moved > 2048) #expect(history.undoActionName == "Reorder Lane") history.undo() #expect(try document(fixture, Ident.lane1).order.value == 1024) history.redo() #expect(try document(fixture, Ident.lane1).order.value == moved) } @Test("⌥⌘↓ undoes the whole permutation, siblings included") func sortRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.select([card1], liveness: .live) store.sortSelection(.down) #expect(history.undoActionName == "Reorder Card") #expect(try document(fixture, card1Path).order.value == 2048) #expect(try document(fixture, card2Path).order.value == 1024) history.undo() #expect(try document(fixture, card1Path).order.value == 1024) #expect(try document(fixture, card2Path).order.value == 2048) history.redo() #expect(try document(fixture, card1Path).order.value == 2048) #expect(try document(fixture, card2Path).order.value == 1024) } } // MARK: - The trash pair @MainActor @Suite("Undo ▸ the trash pair") struct TrashUndoTests { @Test("Undoing a delete is Put Back — the key goes, and nothing else moves") func deleteRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let before = try fixture.indexText(card1Path) store.delete([card1]) #expect(try document(fixture, card1Path).deleted.value != nil) #expect(history.undoActionName == "Delete Card") history.undo() let undone = try fixture.indexText(card1Path) #expect(try FrontmatterDocument.parse(undone).deleted.isMissing) // Byte-identical but for the stamp: the tombstone and its inverse are one key each. #expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before)) history.redo() #expect(try document(fixture, card1Path).deleted.value != nil) } @Test("A multi-item delete is one step with a plural title") func batchDeleteIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1, card2]) #expect(history.undoActionName == "Delete 2 Cards") history.undo() #expect(try document(fixture, card1Path).deleted.isMissing) #expect(try document(fixture, card2Path).deleted.isMissing) #expect(history.canUndo == false) } @Test("A lane delete is named for the lane") func laneDeleteIsNamedForTheLane() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([lane2]) #expect(history.undoActionName == "Delete Lane") history.undo() #expect(try document(fixture, Ident.lane2).deleted.isMissing) } @Test("Undoing a Put Back re-tombstones with the timestamp the row was filed under") func putBackRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let filedUnder = try #require(try document(fixture, trashedPath).deleted.value) store.putBack([trashed]) #expect(try document(fixture, trashedPath).deleted.isMissing) #expect(history.undoActionName == "Restore Card") history.undo() #expect(try document(fixture, trashedPath).deleted.value == filedUnder, "the trash sorts by this — a fresh stamp would reorder a list the user was reading") history.redo() #expect(try document(fixture, trashedPath).deleted.isMissing) } @Test("Drag-to-restore undoes the position half too") func restoreByDragRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let filedUnder = try #require(try document(fixture, trashedPath).deleted.value) store.restoreByDrag(cardID: trashed, intoLane: lane2, at: 0) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) #expect(history.undoActionName == "Restore Card") history.undo() #expect(fixture.exists(trashedPath), "back in the lane it was trashed in") #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false) let undone = try document(fixture, trashedPath) #expect(undone.deleted.value == filedUnder) #expect(undone.order.value == 4096, "at the rank it was trashed holding") history.redo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) } } // MARK: - The Edit session @MainActor @Suite("Undo ▸ the Edit session") struct BodyUndoTests { @Test("A session's saves are one step, registered at the flip, with the body it started from") func sessionIsOneStep() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) // Three debounced ticks inside one session — none of them a step. session.edited("One.\n") _ = session.flush() session.edited("One two.\n") _ = session.flush() session.edited("One two three.\n") _ = session.flush() #expect(history.canUndo == false, "a save tick is not a step") session.endEditSession() #expect(history.undoActionName == "Edit Card") history.undo() #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original, "byte-for-byte, across every tick the session made") history.redo() #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "One two three.\n") } @Test("A session that only read registers nothing") func anUntouchedSessionRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) session.endEditSession() #expect(history.canUndo == false) } @Test("A session typed back to where it started registers nothing") func aRevertedSessionRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body let session = CardBodyEditSession() session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } session.registerUndo = { [weak store] prior, new in store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) } session.adopt(diskBody: original) session.edited("Something else.\n") _ = session.flush() session.edited(original) _ = session.flush() session.endEditSession() #expect(history.canUndo == false, "the net effect on the file is nothing to undo") #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original) } } // MARK: - What registers nothing @MainActor @Suite("Undo ▸ the operations that register nothing") struct NotUndoableTests { @Test("Delete Immediately registers nothing — the confirm is the safety") func purgeRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) let armed = try #require(history.undoActionName) store.deleteImmediately([trashed]) #expect(fixture.exists(trashedPath) == false) #expect(store.purgeIsUnrecoverable) #expect(history.undoActionName == armed, "the stack is exactly where the purge found it") } @Test("Empty Trash registers nothing either") func emptyTrashRegistersNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.emptyTrash() #expect(fixture.exists(trashedPath) == false) #expect(history.canUndo == false) } @Test("Attachment add and remove register nothing in v1") func attachmentsRegisterNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) let source = try fixture.file("outside/note.txt", Data("hello".utf8)) store.importAttachments([source], toCard: card1) #expect(fixture.exists("\(card1Path)/attachments") ) #expect(history.canUndo == false) store.removeAttachment(named: "note.txt", fromCard: card1) #expect(history.canUndo == false) } @Test("A checkbox toggle and a raw-source Apply are outside 13's inventory") func bodyAdjacentWritesRegisterNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) _ = store.applyCardSource(inCard: card1, text: "---\nschema: 1\norder: 1024\n---\nApplied.\n") #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Applied.\n") #expect(history.canUndo == false) } @Test("A store with no stack behind it writes exactly as it always did") func aStorelessBoardStillWrites() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.delete([card1]) store.setLaneWidth(lane1, units: 2) #expect(try document(fixture, card1Path).deleted.value != nil) #expect(try document(fixture, Ident.lane1).width.value == 2) #expect(store.banners.oneShots.isEmpty) } } // MARK: - The crossing is a write @MainActor @Suite("Undo ▸ crossings are ordinary writes") struct CrossingIsAWriteTests { @Test("An undo goes through the Writer: it stamps, and it echoes back through the reload") func undoIsAnAppMediatedWrite() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() history.undo() let undone = try document(fixture, card1Path) #expect(undone.title.value == "First") let stamped = try #require(undone.modified.value) #expect(stamped.timeIntervalSinceNow > -30, "an inverse is a real write, not an in-memory revert") #expect(try fixture.indexText(card1Path).contains("modified-by") == false) // And the board sees it the only way it ever sees anything: through a reload. await reload(store) let card = try #require(store.snapshot.lanes.first?.cards.first { $0.id == card1 }) #expect(card.title.value == "First") } @Test("Undo, redo, undo — the classic dance over one file") func theDanceRepeats() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) for _ in 0 ..< 3 { history.undo() #expect(try document(fixture, card1Path).deleted.isMissing) history.redo() #expect(try document(fixture, card1Path).deleted.value != nil) } } } // MARK: - The phrase vocabulary @Suite("Undo ▸ the phrase vocabulary") struct HistoryPhraseTests { @Test("Singular and plural follow 06's folding") func pluralFolding() { #expect(HistoryPhrase.name(.move, kind: .card) == "Move Card") #expect(HistoryPhrase.name(.move, kind: .card, count: 3) == "Move 3 Cards") #expect(HistoryPhrase.name(.rename, kind: .lane) == "Rename Lane") #expect(HistoryPhrase.name(.resize, kind: .lane, count: 2) == "Resize 2 Lanes") #expect(HistoryPhrase.name(.delete, kind: .card, count: 12) == "Delete 12 Cards") } @Test("A count of one or less is the singular, and the board is always singular") func degenerateCounts() { #expect(HistoryPhrase.name(.add, kind: .card, count: 1) == "Add Card") #expect(HistoryPhrase.name(.add, kind: .card, count: 0) == "Add Card") #expect(HistoryPhrase.name(.restyle, kind: .board, count: 4) == "Restyle Board") } @Test("The phrase never spells the verb the platform composes") func noUndoPrefix() { for verb in HistoryPhrase.Verb.allCases { let phrase = HistoryPhrase.name(verb, kind: .card) #expect(phrase.hasPrefix("Undo") == false) #expect(phrase.hasPrefix("Redo") == false) } } }