import Foundation import Testing @testable import Kanban /// **The two-level undo model** — a card window's own stack, and the one coarse step its close /// registers on the board's (13-native-undo.md ▸ Rules, re-ruled 2026-07-31; 05-card-window.md ▸ The /// comments column; 01-storage-format.md § Enhanced schema ▸ the deferred purge). /// /// The claims here are all about *which stack* and *when*, which is exactly the class of thing that /// looks right in a running window and is wrong: a step on the board's stack while a window is still /// open, a purge that ran while an undo still needed the folder it removed, a session that registered /// a step for a card that had left the board. So every test drives the production wiring /// (`CardWindowHost.configureUndo` / `.configureComments`, both `static` for this reason) over a real /// store and asserts against the two stacks and the bytes on disk. /// /// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider /// grammar is `HistoryProviderTests`'. // MARK: - The window under test @MainActor private struct Window { let store: BoardStore /// The **board's** stack — what must stay empty while the window is open. let board: NativeHistoryProvider let session: CardWindowSession var window: CardWindowUndo { session.undo } var comments: CardComments { session.comments } var body: CardBodyEditSession { session.body } } private let cardID = ItemID(rawValue: Ident.card1) private let cardPath = "\(Ident.lane1)/\(Ident.card1)" /// **The coarse close step's row, spelled once** — "Changes to '⟨card⟩'" (13-native-undo.md ▸ Rules, /// ruled 2026-07-31), over the fixture card's own title. Pinned as a value here and asserted verbatim /// in `theCoarseStepNamesItsCard` below, so a suite that reads the phrase eleven times still only /// *decides* it once. private let coarseStep = "Changes to 'Fix login'" /// A card window over a board with one card, wired exactly as `CardWindowHost` wires one. @MainActor private func makeWindow(_ fixture: WriterFixture) throws -> Window { let store = try BoardStore(rootURL: fixture.root) let board = NativeHistoryProvider() store.history = board return try openWindow(fixture, store: store, board: board) } /// **The same card opened again, over the board that is already holding the last session's step** — /// the reopen 13's sweep gate is about (ruled 2026-07-31): a second window is a second session with /// its own empty stack, and the *board's* stack is the one that survived the close. @MainActor private func reopen(_ window: Window, _ fixture: WriterFixture) throws -> Window { try openWindow(fixture, store: window.store, board: window.board) } @MainActor private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: NativeHistoryProvider) throws -> Window { let session = CardWindowSession() CardWindowHost.configureUndo(session, store: store, cardID: cardID) CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) session.comments.isEditable = true session.comments.cardFolder = fixture.url(cardPath) // The one seam the host wires beside the undo ones and this suite is not testing — the buffer's // write target (`configureSession`), spelled the same way. session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished } session.comments.open() session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body) return Window(store: store, board: board, session: session) } @MainActor private func editBody(_ window: Window, to text: String) { window.body.edited(text) window.body.endEditSession() } @MainActor private func postComment(_ window: Window, body: String) -> ItemID? { window.comments.composer.edited(body) _ = window.comments.composer.flush() return window.comments.composer.postNow() } private func body(_ fixture: WriterFixture, _ path: String) throws -> String { try FrontmatterDocument.parse(fixture.indexText(path)).body } // MARK: - Routing @MainActor @Suite("Card session undo ▸ routing") struct CardSessionRoutingTests { @Test("A body edit and a comment post register on the window's stack, never on the board's") func windowGesturesStayOffTheBoardStack() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) editBody(window, to: "Edited in the window.\n") #expect(postComment(window, body: "A remark.\n") != nil) #expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps") #expect(window.window.stack.canUndo) #expect(window.window.stack.undoActionName == "Comment") } @Test("⌘Z in the window walks its own gestures, newest first") func theWindowStackWalksItsOwnGestures() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let window = try makeWindow(fixture) let original = try body(fixture, cardPath) editBody(window, to: "Edited in the window.\n") let posted = try #require(postComment(window, body: "A remark.\n")) // Newest first: the post, then the body edit. window.window.stack.undo() #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)")) #expect(fixture.exists("\(card)/comments/.draft")) #expect(try body(fixture, cardPath) == "Edited in the window.\n") window.window.stack.undo() #expect(try body(fixture, cardPath) == original) #expect(!window.window.stack.canUndo) #expect(!window.board.canUndo, "and the board's stack was never involved") } @Test("A comment's inline edit session is one window step, at its commit point") func inlineEditRegistersOneWindowStep() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText(body: "original\n")) let window = try makeWindow(fixture) window.comments.reload() window.comments.beginEdit(ItemID(rawValue: CommentIdent.one)) window.comments.editing?.edited("first pass\n") window.comments.editing?.flush() window.comments.editing?.edited("second pass\n") window.comments.editing?.flush() #expect(!window.window.stack.canUndo, "a save tick is not a step") window.comments.commitEdit() #expect(window.window.stack.undoActionName == "Edit Comment") #expect(!window.board.canUndo) window.window.stack.undo() #expect(try body(fixture, path) == "original\n", "back to the bytes the session opened on") } @Test("An inline session's Cancel registers nothing — its net effect is nothing") func cancelRegistersNothing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n")) let window = try makeWindow(fixture) window.comments.reload() window.comments.beginEdit(ItemID(rawValue: CommentIdent.one)) window.comments.editing?.edited("rewritten\n") window.comments.editing?.flush() window.comments.cancelEdit() #expect(!window.window.stack.canUndo) #expect(!window.board.canUndo) } @Test("The window's manager answers for the window's stack, and never falls through") func theWindowManagerNeverFallsThrough() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) // A board step exists — a rename made from the board window, say. window.store.renameBoard("Renamed") #expect(window.board.canUndo) // The window's own stack is empty, so its manager says no: "exhausting the window stack // beeps; it never reaches board history" (06-history-undo.md ▸ Undo routing). #expect(!window.window.manager.canUndo) #expect(!window.window.manager.canRedo) #expect(window.window.manager.undoMenuItemTitle == "Undo") editBody(window, to: "Edited.\n") #expect(window.window.manager.canUndo) #expect(window.window.manager.undoMenuItemTitle == "Undo Edit Card") } @Test("The read-only lock disables the window's pair, and the stack survives it") func theLockDisablesTheWindowsPair() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) editBody(window, to: "Edited.\n") #expect(window.window.manager.canUndo) window.store.enterVanishedRootLock() #expect(!window.window.manager.canUndo, "13 ▸ Rules ▸ locks, one level down") #expect(window.window.stack.canUndo, "the stack itself is untouched") } } // MARK: - The coarse close step @MainActor @Suite("Card session undo ▸ the coarse close step") struct CardSessionCloseTests { @Test("A session of three gestures closes as exactly one board step, and undo restores all three") func oneStepForTheWholeSession() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let deleted = commentPath(CommentIdent.one, inCard: card) try fixture.item(deleted, commentText(body: "kept somewhere\n")) let window = try makeWindow(fixture) window.comments.reload() let original = try body(fixture, cardPath) editBody(window, to: "Edited in the window.\n") let posted = try #require(postComment(window, body: "A remark.\n")) window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() // One step, named for the session rather than for any gesture inside it. #expect(window.board.canUndo) #expect(window.board.undoActionName == coarseStep) window.board.undo() #expect(!window.board.canUndo, "exactly one") #expect(try body(fixture, cardPath) == original) #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"), "the post is unposted") #expect(fixture.exists("\(card)/comments/.draft")) #expect(fixture.exists(deleted), "the deleted comment is back — restored from comments/.trash/") #expect(try body(fixture, deleted) == "kept somewhere\n") window.board.redo() #expect(try body(fixture, cardPath) == "Edited in the window.\n") #expect(fixture.exists("\(card)/comments/\(posted.rawValue)")) #expect(!fixture.exists(deleted)) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) } @Test("The coarse step is named for its card — 'Changes to ⟨title⟩', never the fine 'Edit Card'") func theCoarseStepNamesItsCard() async throws { // 13-native-undo.md ▸ Rules, ruled 2026-07-31: "one coarse step named 'Changes to ⟨card⟩' — // the board row reads 'Undo Changes to ⟨Fix login⟩': plural and scope-flavoured, distinct from // every fine verb … the fine body-edit wording never leaks onto the board menu." let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) editBody(window, to: "Edited in the window.\n") #expect(window.window.stack.undoActionName == "Edit Card", "the fine body edit keeps its verb — this is the collision the ruling resolves") await window.session.endSession() #expect(window.board.undoActionName == "Changes to 'Fix login'") #expect(window.board.undoActionName == coarseStep) // The "Undo " prefix is the platform's, composed over the bare phrase (`BoardUndoManager`). #expect(BoardUndoManager(history: window.board).undoMenuItemTitle == "Undo Changes to 'Fix login'") // An untitled card renders the placeholder its own window title bar renders — "Untitled" is a // rendering, never a value (03-board-ui.md § Card face). #expect(HistoryPhrase.cardSession(nil) == "Changes to 'Untitled'") } @Test("A session with no net change registers nothing") func noNetChangeRegistersNothing() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) let original = try body(fixture, cardPath) // Two Edit sessions whose net effect on the file is nothing at all. editBody(window, to: "A detour.\n") editBody(window, to: original) #expect(window.window.stack.canUndo, "two real gestures, on the window's stack") await window.session.endSession() #expect(!window.board.canUndo, "a session with no net change registers nothing") #expect(try body(fixture, cardPath) == original) } @Test("A gesture undone inside the window is not part of the session's net effect") func anUndoneGestureLeavesTheFold() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText()) let window = try makeWindow(fixture) window.comments.reload() window.comments.delete(ItemID(rawValue: CommentIdent.one)) window.window.stack.undo() #expect(fixture.exists(path), "the window's own ⌘Z put it back") await window.session.endSession() #expect(!window.board.canUndo, "nothing is left of the session to coarsen") } @Test("A comment edited and then deleted in one session folds to where it actually is") func editThenDeleteFoldsToTheTrash() async throws { // The fold's move case: the edit's expectations name the comment's live path, and the delete // then empties that path. A fold that kept both would be stale at the moment it registered. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText(body: "original\n")) let window = try makeWindow(fixture) window.comments.reload() window.comments.beginEdit(ItemID(rawValue: CommentIdent.one)) window.comments.editing?.edited("rewritten\n") window.comments.commitEdit() window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() #expect(window.board.undoActionName == coarseStep) window.board.undo() #expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival") #expect(fixture.exists(path)) #expect(try body(fixture, path) == "original\n", "restored to the bytes the session opened on") #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) } @Test("A comment posted and then deleted in one session unwinds to the draft it came from") func postThenDeleteUnwindsToTheDraft() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let window = try makeWindow(fixture) let posted = try #require(postComment(window, body: "A remark.\n")) window.comments.reload() window.comments.delete(posted) await window.session.endSession() window.board.undo() #expect(window.store.banners.signposts.isEmpty) #expect(fixture.exists("\(card)/comments/.draft")) #expect(try body(fixture, "\(card)/comments/.draft") == "A remark.\n") #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)")) #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @Test("A window whose card was trashed still registers — the trash is a relocation, not a vanishing") func aTrashedCardStillRegistersItsSession() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) let original = try body(fixture, cardPath) editBody(window, to: "Edited.\n") // The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which // dismisses this window (05-card-window.md ▸ Deletion & lifecycle). window.store.deleteCard(cardID) window.store.handleWatcherEvent(.treeChanged(.appMediated)) await window.store.awaitQuiescence() #expect(window.board.undoActionName == "Delete Card") await window.session.endSession() // "A tracked relocation — a lane move mid-session or after close, **a trash move** — never // stales the step" (13 ▸ Rules, ruled 2026-07-31): the session resolves through the walk that // spans both containers, so the step registers over the delete rather than being dropped. #expect(window.board.undoActionName == coarseStep) window.board.undo() #expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed") #expect(try body(fixture, ".trash/\(Ident.card1)") == original, "walked back where the card is now, its subtree intact") #expect(window.board.undoActionName == "Delete Card", "and the delete is the next step down") } @Test("A card that resolves nowhere registers nothing") func aCardThatResolvesNowhereRegistersNoSessionStep() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) editBody(window, to: "Edited.\n") // Purged, or moved out of the board — neither container holds the card, so there is no folder // for the step's components to be about and "the honest skip" is not to register one at all. try FileManager.default.removeItem(at: fixture.url(cardPath)) window.store.handleWatcherEvent(.treeChanged(.foreign)) await window.store.awaitQuiescence() await window.session.endSession() #expect(!window.board.canUndo, "no session step for a card that is nowhere") } @Test("A style change made in the window's sidebar joins the session, not the board") func sidebarStylingIsASessionGesture() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) // The sidebar anchor's write, exactly as `CardStyleSection` makes it. window.store.applyStyle( to: CardStyleSection.target(forCard: cardID), background: .set("blue"), on: window.window ) #expect(window.window.stack.undoActionName == "Restyle Card") #expect(!window.board.canUndo) await window.session.endSession() #expect(window.board.undoActionName == coarseStep) window.board.undo() let document = try FrontmatterDocument.parse(fixture.indexText(cardPath)) #expect(document.background.isMissing, "the session's net effect, walked back") } } // MARK: - Transactional staleness @MainActor @Suite("Card session undo ▸ transactional staleness") struct CardSessionStalenessTests { @Test("One stale component skips the whole step — nothing is partially reverted") func anyStaleComponentSkipsTheWholeStep() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let deleted = commentPath(CommentIdent.one, inCard: card) try fixture.item(deleted, commentText()) let window = try makeWindow(fixture) window.comments.reload() editBody(window, to: "Edited in the window.\n") window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() #expect(window.board.canUndo) // Somebody else rewrites the card's body after the window closed — one component of the // session's step, and only one. _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n") window.board.undo() #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write") #expect(!fixture.exists(deleted), "and the comment half did not half-happen either") #expect(!window.board.canUndo, "the stale step was popped") #expect(window.store.banners.signposts.map(\.message) == ["Undo skipped — 'Fix login' changed outside Lanework"]) } @Test("A foreign change to a field the session never wrote leaves the step alone") func anUnrelatedForeignChangeDoesNotSkip() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) let original = try body(fixture, cardPath) editBody(window, to: "Edited in the window.\n") await window.session.endSession() // A foreign styling of the same card: the session wrote the body and nothing else, so the // step names no style field to be stale against (13 ▸ Rules, the field-level predicate). try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) { $0.set(FrontmatterKeys.background, to: .string("blue")) } window.board.undo() #expect(try body(fixture, cardPath) == original) #expect(window.store.banners.signposts.isEmpty) } } // MARK: - Identity anchoring /// **Session steps anchor by card identity, never by path** (13-native-undo.md ▸ Rules, ruled /// 2026-07-31) — the defect these tests exist for, stated as the gesture that produced it: a card /// window's steps carried lane-bearing folder paths, so one board-side lane move staled *every* /// component of the session step at once and the whole session skipped, though nothing about the /// card's content had changed. /// /// The claim is not "moves are ignored". It is that a **relocation** and a **content change** are /// different questions and only the second is what validation exists to catch — so the collision half /// is pinned here beside the round trip, at the card's *new* home. @MainActor @Suite("Card session undo ▸ identity anchoring") struct CardSessionAnchorTests { /// The one-lane comment board with somewhere to move the card to. @MainActor private func twoLaneBoard(_ fixture: WriterFixture) throws -> String { let card = try makeCommentBoard(fixture) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) return card } /// The card's folder after a move into the second lane. private let movedPath = "\(Ident.lane2)/\(Ident.card1)" @Test("A board-side lane move never stales the session — all three gestures still apply") func aLaneMoveDoesNotStaleTheSessionStep() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try twoLaneBoard(fixture) let deleted = commentPath(CommentIdent.one, inCard: card) try fixture.item(deleted, commentText(body: "kept somewhere\n")) let window = try makeWindow(fixture) window.comments.reload() let original = try body(fixture, cardPath) editBody(window, to: "Edited in the window.\n") let posted = try #require(postComment(window, body: "A remark.\n")) window.comments.delete(ItemID(rawValue: CommentIdent.one)) // The board moves the card to another lane while its window is open — a drag on the board, // which is a board gesture on the board's own stack. Every folder the session wrote to has // just changed path. window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0) window.store.handleWatcherEvent(.treeChanged(.appMediated)) await window.store.awaitQuiescence() #expect(fixture.exists(movedPath)) #expect(window.board.undoActionName == "Move Card") await window.session.endSession() #expect(window.board.undoActionName == coarseStep, "the session registered over the move") window.board.undo() #expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision") #expect(try body(fixture, movedPath) == original, "the body is back — at the card's new home") #expect(!fixture.exists("\(movedPath)/comments/\(posted.rawValue)"), "the post is unposted") #expect(fixture.exists("\(movedPath)/comments/.draft")) #expect(fixture.exists("\(movedPath)/comments/\(CommentIdent.one)"), "and the deleted comment is back, out of the trash that travelled with the card") #expect(window.board.undoActionName == "Move Card", "the move is the next step down") } @Test("A genuine field collision at the card's new home still skips the whole step") func aFieldCollisionAfterAMoveStillSkipsWhole() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try twoLaneBoard(fixture) let deleted = commentPath(CommentIdent.one, inCard: card) try fixture.item(deleted, commentText(body: "kept somewhere\n")) let window = try makeWindow(fixture) window.comments.reload() editBody(window, to: "Edited in the window.\n") window.comments.delete(ItemID(rawValue: CommentIdent.one)) window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0) window.store.handleWatcherEvent(.treeChanged(.appMediated)) await window.store.awaitQuiescence() await window.session.endSession() // Somebody else rewrites the body — at the folder the card now sits in, which is exactly where // the anchor resolves. One component, and the whole step goes (13: "any stale component skips // the whole step — never a partial session revert"). _ = try BoardWriter.writeBody(inItemFolder: fixture.url(movedPath), body: "Somebody else.\n") window.board.undo() #expect(window.store.banners.signposts.map(\.message) == ["Undo skipped — 'Fix login' changed outside Lanework"]) // The skip pops the step and **⌘Z falls through to the next one down** (13 ▸ Rules), which // here is the board's own move — so the card, the foreign body and the still-deleted comment // all travel back to the lane the move took them from. #expect(fixture.exists(cardPath)) #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write") #expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"), "and the comment half did not half-happen either") #expect(fixture.exists("\(cardPath)/comments/.trash/\(CommentIdent.one)"), "the skip is not a clean exit — the backing it held survives to the session's end") #expect(!window.board.canUndo, "both steps are gone — one skipped, one applied") } @Test("The window's own ⌘Z survives a lane move too — the fine steps carry the same anchors") func theWindowStackSurvivesALaneMove() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try twoLaneBoard(fixture) let window = try makeWindow(fixture) let original = try body(fixture, cardPath) editBody(window, to: "Edited in the window.\n") window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0) window.store.handleWatcherEvent(.treeChanged(.appMediated)) await window.store.awaitQuiescence() // "The coarse step — **and the window's fine steps it folds**" (13 ▸ Rules): the fold is only // as sound as its components, so the window's own stack is anchored the same way. window.window.stack.undo() #expect(try body(fixture, movedPath) == original) #expect(window.store.banners.signposts.isEmpty) } } // MARK: - The deferred purge @MainActor @Suite("Card session undo ▸ the deferred purge") struct CardSessionPurgeTests { /// A window whose session deleted one comment and closed — the state every test below starts in. @MainActor private func closedWithADeletedComment(_ fixture: WriterFixture) async throws -> (Window, String) { let card = try makeCommentBoard(fixture) let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText()) let window = try makeWindow(fixture) window.comments.reload() window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() return (window, card) } /// The same close, collided with — **the coarse step popped as stale, having applied nothing**, /// which is the state the skip-purge decoupling is about (13 ▸ Interaction with the trash, ruled /// 2026-07-31). /// /// **The body edit is what makes this a skip at all.** A session of nothing but the delete names /// only the comment, and a foreign write to the *card* would leave it perfectly current — the undo /// would then apply, restore the comment, and empty the trash by moving its one entry out, which /// is the same disk state for entirely the wrong reason. With the body in the step, a foreign body /// rewrite stales it **without touching what the purge would remove**, which is the only way to /// ask what the skip did to the backing. @MainActor private func skippedOnAForeignEdit(_ fixture: WriterFixture) async throws -> (Window, String) { let card = try makeCommentBoard(fixture) try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) let window = try makeWindow(fixture) window.comments.reload() editBody(window, to: "Edited in the window.\n") window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n") window.board.undo() #expect(window.store.banners.signposts.map(\.message) == ["Undo skipped — 'Fix login' changed outside Lanework"]) #expect(!window.board.canUndo, "the stale step was popped") return (window, card) } @Test("comments/.trash survives the close while the coarse step lives") func theTrashOutlivesTheClose() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await closedWithADeletedComment(fixture) #expect(window.board.canUndo) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the step's undo restores from here — the purge waits for it") } // MARK: The reopen — 13's sweep gate @Test("Reopening the card window never destroys the last session's undo backing") func theReopenSweepSparesTheCoarseStepsBacking() async throws { // The ship-blocker this gate exists for (13 ▸ Interaction with the trash, ruled 2026-07-31): // the open-time residue sweep used to empty `comments/.trash/` unconditionally, so opening the // card again threw away the folder the board's own coarse step was about to restore from. let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await closedWithADeletedComment(fixture) let path = commentPath(CommentIdent.one, inCard: card) // The reopen. `CardComments.open()` runs the sweep before it reads the thread, exactly as the // host wires it — so this is the production sequence, not a re-typed copy of it. _ = try reopen(window, fixture) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the sweep asked the board's stack first: this is backing, not residue") window.board.undo() #expect(window.store.banners.signposts.isEmpty, "nothing was stale — the backing was still there") #expect(fixture.exists(path), "the coarse step's undo restored the comment the reopen spared") } @Test("Residue no live step owns still sweeps at the open, beside backing that does") func unownedResidueStillSweeps() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await closedWithADeletedComment(fixture) // A crashed session's leftovers, landing beside the live step's backing: nothing on the stack // names this one, so it is residue by the ruling's own definition. try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) _ = try reopen(window, fixture) #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "unowned content sweeps as before") #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "and owned content is left alone") #expect(window.store.heals.memo(for: .commentTrashResidue) == nil, "the signature is the entries actually purged, and it cleared on success") } @Test("Once the hold ends, the purge the sweep deferred to runs") func theRetirementRunsThePurgeTheSweepDeferredTo() async throws { // "One condition, two consumers" (13): the sweep spared this content because a step owned it, // and the moment that ownership ends is the moment the deferred purge was always waiting for. // The arc — spared, then released, then purged — is what this proves; *which* release ends it // is the two tests below and the two below the divider. let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await closedWithADeletedComment(fixture) _ = try reopen(window, fixture) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "spared by the reopen's sweep") window.board.clear() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "and the purge the sweep had deferred to ran with it") } @Test("The board session's end purges what the step was holding") func theBoardSessionsEndPurges() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await closedWithADeletedComment(fixture) // `AppModel`'s teardown, and the add-git swap, both do exactly this. window.board.clear() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } // MARK: The skip — the one exit that is not clean @Test("A stale skip purges nothing — the backing it was holding survives") func aSkippedStepsBackingSurvives() async throws { // The decoupling ruled 2026-07-31 (13 ▸ Interaction with the trash), and its reason: "the skip // banner says nothing was applied, and an irreversible purge riding that gesture would be // surprise loss — the skip is exactly when the user may want to inspect what the collision // left". This is the pin that used to say the opposite. let fixture = try WriterFixture() defer { fixture.tearDown() } let (_, card) = try await skippedOnAForeignEdit(fixture) #expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)), "the step skipped rather than applied — nothing was restored") #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "and nothing was destroyed either: the bytes are still there to look at") } @Test("Reopening the card after a skip spares the survivor — the same hold, read twice") func theSweepSparesASkipSurvivor() async throws { // The half that makes "survives to board-session end" true rather than merely intended: the // step is off both stacks, so a sweep that read only those would call this residue and purge // it at the very next open — taking the ruling back one window later. let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await skippedOnAForeignEdit(fixture) _ = try reopen(window, fixture) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the sweep asked the same hold the purge is still waiting on") } @Test("Unowned residue beside a skip's survivor still sweeps") func residueSweepsBesideASkipSurvivor() async throws { // The gate did not become "spare everything in there": a stranded step's claim is exactly as // narrow as a live one's, one anchor at a time. let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await skippedOnAForeignEdit(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) _ = try reopen(window, fixture) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the survivor is owned") #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "the crash leftover is not") } @Test("The board session's end purges a skip's survivor too") func theBoardSessionsEndPurgesASkipSurvivor() async throws { // "A stale-skipped step's backing instead survives **to board-session end**" — the deferral // has a floor, and it is the same one every other hold has (`AppModel`'s teardown). let fixture = try WriterFixture() defer { fixture.tearDown() } let (window, card) = try await skippedOnAForeignEdit(fixture) window.board.clear() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @Test("A session that registers no step purges at the close, as it always did") func aSessionWithNoStepPurgesAtOnce() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) // Residue with no session behind it: nothing this close does could need it back. try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) let window = try makeWindow(fixture) await window.session.endSession() #expect(!window.board.canUndo) #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @Test("A substrate that keeps no steps purges at the close — Pro's rule, structurally") func aSubstrateThatKeepsNoStepsPurgesAtOnce() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText()) let window = try makeWindow(fixture) window.comments.reload() // The git provider drops every registration (its substrate is the commit trail) and retires // it on the way past — which is what makes "purge rides the close flush" true on Pro with no // tier check at any call site. Bound directly here: `register` reads no repository. window.store.history = GitHistoryProvider(boardRoot: fixture.root) window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @Test("Undone-and-superseded releases the work the step was holding") func supersedingAnUndoneStepRetiresIt() { // The one release condition disk cannot show, because by the time it fires the coarse undo // has already emptied the trash it was holding: a step undone and then superseded by a new // gesture leaves history for good, and its retirement runs (13 ▸ Interaction with the trash). let provider = NativeHistoryProvider() let retirement = HistoryStep.Retirement {} provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) #expect(retirement.isOwed) provider.undo() #expect(retirement.isOwed, "an undone step is still crossable — redo would need its backing") provider.register(HistoryStep(name: "Move Card", undo: { _ in .applied }, redo: { _ in .applied })) #expect(!retirement.isOwed, "the redo stack cleared, so the step is gone for good") } @Test("A retirement runs once, whatever a provider does to the step") func aRetirementRunsOnce() { var runs = 0 let retirement = HistoryStep.Retirement { runs += 1 } let provider = NativeHistoryProvider() provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) provider.clear() provider.clear() #expect(runs == 1) } @Test("A board with no substrate at all owes the purge immediately") func noProviderRunsTheRetirementAtOnce() throws { // A store with no session behind it — no board the app composes binds no provider any more // (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: repo-nested boards bind the native // stack too). Nothing could ever report the step's death, so the work is owed at registration // or never. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) let store = try BoardStore(rootURL: fixture.root) let session = CardWindowSession() CardWindowHost.configureUndo(session, store: store, cardID: cardID) CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) session.comments.isEditable = true session.comments.cardFolder = fixture.url(cardPath) var purged = false _ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true }) #expect(purged == false, "no net effect — nothing was registered, and the caller still owes it") session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished } session.body.adopt(diskBody: try body(fixture, cardPath)) session.body.edited("Edited.\n") session.body.endEditSession() _ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true }) #expect(purged, "a step nothing keeps is a step nothing can retire later") } } // MARK: - The fold @MainActor @Suite("Card session undo ▸ the fold") struct CardSessionFoldTests { private let folder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true) @Test("Merging is per target and per field, later entries winning") func laterEntriesWin() { let other = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true) let fold = CardWindowUndo.fold([ [.present(folder, .background("blue"), .body("first\n"))], [.present(folder, .background("green")), .present(other)], ]) #expect(fold.expectations == [ .present(folder, .background("green"), .body("first\n")), .present(other), ]) } @Test("Two folds of the same writes, read from both ends, are equal only when nothing changed") func equalityIsTheNoNetChangeTest() { let there = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("b\n"))]]) let back = CardWindowUndo.fold([[.present(folder, .body("b\n"))], [.present(folder, .body("a\n"))]]) #expect(there != back) let round = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("a\n"))]]) #expect(round == CardWindowUndo.fold([[.present(folder, .body("a\n"))]])) } @Test("Presence takes the later answer, and an absence clears what was said about the path") func presenceTakesTheLaterAnswer() { let live = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true) // An edit, then the delete that emptied the path: a fold still expecting the edited bytes // there would be stale on arrival. let fold = CardWindowUndo.fold([[.present(live, .body("rewritten\n"))], [.absent(live)]]) #expect(fold.expectations == [.absent(live)]) // And back again — a restore names the path present, which is the ordinary later-wins. let restored = CardWindowUndo.fold([[.absent(live)], [.present(live, .body("original\n"))]]) #expect(restored.expectations == [.present(live, .body("original\n"))]) } }