import Foundation import Testing @testable import Kanban /// The **write** half of the comment thread: the five Writer primitives, the two move-based inverses /// registered at the store boundary, and the copy-boundary matrix (01-storage-format.md § Enhanced /// schema; 05-card-window.md ▸ The comments column; 13-native-undo.md). /// /// Assertions are against the **bytes on disk**, like every other write suite here — a comment that /// is merely equivalent in a model is not the claim. `WriterFixture`, `Ident` and `Item` come from /// `WriterTestSupport.swift`; `CommentIdent`, `commentText` and `makeCommentBoard` from /// `CommentThreadTests.swift`. // MARK: - Helpers private let cardTitle = "Fix login" private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument { try FrontmatterDocument.parse(fixture.indexText(relativePath)) } /// The one comment folder in a thread, as a path — the posted identity is minted, so a test that /// posts has to find it rather than name it. private func postedNames(_ fixture: WriterFixture, inCard card: String) throws -> [String] { try fixture.entryNames("\(card)/comments") .filter { IntegrityRules.isIdentityShaped($0) } .sorted() } @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) } // MARK: - The draft @Suite("Comments ▸ draft lifecycle") struct CommentDraftTests { @Test("The first save creates .draft with the comment schema, no title and no order") func createStampsTheSchema() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let outcome = try BoardWriter.saveCommentDraft( inCard: fixture.url(card), body: "Half a thought", cardTitle: cardTitle ) #expect(outcome == .created) let draft = "\(card)/comments/.draft" let text = try fixture.indexText(draft) let parsed = try FrontmatterDocument.parse(text) #expect(parsed.schema.value == 1) #expect(parsed.kind.value == "comment") #expect(parsed.author.value == NSFullUserName()) #expect(parsed.created.value != nil) // One `Date` for both stamps, so a draft never reads as edited before it is posted. #expect(parsed.created.value == parsed.modified.value) #expect(parsed.title.isMissing, "a comment has no title") #expect(parsed.order.isMissing, "a comment has no order") #expect(parsed.body == "Half a thought") #expect(text.hasPrefix("---\nschema: 1\n")) } @Test("An update replaces the body span and stamps modified; author and created stay") func updateWritesTheBody() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let draft = "\(card)/comments/.draft" try fixture.item(draft, commentText()) let outcome = try BoardWriter.saveCommentDraft( inCard: fixture.url(card), body: "Second thought\n", cardTitle: cardTitle ) #expect(outcome == .updated) let parsed = try document(fixture, draft) #expect(parsed.body == "Second thought\n") #expect(parsed.author.value == "Ada Lovelace") #expect(parsed.created.value == parsed.created.value) #expect(parsed.modified.value != parsed.created.value) // The unknown key, its inline comment and its position are untouched. #expect(try fixture.indexText(draft).contains("project: lanework # agent overlay")) } @Test("Identical bytes write nothing at all — the ~30 s tick must not stamp") func unchangedWritesNothing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let draft = "\(card)/comments/.draft" try fixture.item(draft, commentText(body: "Same\n")) let before = try fixture.indexData(draft) let stamp = try FileManager.default .attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date let outcome = try BoardWriter.saveCommentDraft( inCard: fixture.url(card), body: "Same\n", cardTitle: cardTitle ) #expect(outcome == .unchanged) #expect(try fixture.indexData(draft) == before) let after = try FileManager.default .attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date #expect(after == stamp) } @Test("A draft emptied of text with no attachments deletes its folder — never litter") func emptiedDraftIsDeleted() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText(body: "typed\n")) let outcome = try BoardWriter.saveCommentDraft( inCard: fixture.url(card), body: " \n\n", cardTitle: cardTitle ) #expect(outcome == .deleted) #expect(!fixture.exists("\(card)/comments/.draft")) #expect(fixture.exists("\(card)/comments"), "the container stays — the next draft mints into it") } @Test("A draft emptied of text but holding attachments survives") func emptiedDraftWithAttachmentsSurvives() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let draft = "\(card)/comments/.draft" try fixture.item(draft, commentText(body: "typed\n")) try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8)) let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle) #expect(outcome == .updated) #expect(try document(fixture, draft).body == "") #expect(fixture.exists("\(draft)/attachments/shot.png")) } @Test("An empty save against no draft creates nothing") func emptySaveCreatesNothing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle) #expect(outcome == .unchanged) #expect(!fixture.exists("\(card)/comments")) } @Test("An interrupted create — a draft folder with no index.md — is filled in, not refused") func indexlessDraftIsFilledIn() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try FileManager.default.createDirectory( at: fixture.url("\(card)/comments/.draft"), withIntermediateDirectories: true ) #expect(try BoardWriter.saveCommentDraft( inCard: fixture.url(card), body: "recovered\n", cardTitle: cardTitle ) == .created) #expect(try document(fixture, "\(card)/comments/.draft").body == "recovered\n") } @Test("A draft is not a card write: only a card folder takes one") func refusesNonCards() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try makeCommentBoard(fixture) let failure = writeFailure { try BoardWriter.saveCommentDraft(inCard: fixture.url(Ident.lane1), body: "x", cardTitle: nil) } #expect(failure?.operation == .saveCommentDraft(title: nil)) } } // MARK: - Posting @Suite("Comments ▸ post") struct CommentPostTests { @Test("Posting renames the draft to a fresh lowercase identity and restamps both dates") func postRenamesAndRestamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let draft = "\(card)/comments/.draft" try fixture.item(draft, commentText(created: "2020-01-01T09:00:00Z", modified: "2020-01-01T09:00:00Z")) try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8)) let draftBody = try document(fixture, draft).body let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) #expect(!fixture.exists(draft)) #expect(posted.id.rawValue == posted.id.rawValue.lowercased()) #expect(IntegrityRules.isIdentityShaped(posted.id.rawValue)) #expect(try postedNames(fixture, inCard: card) == [posted.id.rawValue]) let path = "\(card)/comments/\(posted.id.rawValue)" let parsed = try document(fixture, path) #expect(parsed.created.value == parsed.modified.value, "post time is one instant, not two") #expect(parsed.created.value.map { abs($0.timeIntervalSince(posted.posted)) < 1 } == true) #expect(parsed.created.value != nil && parsed.created.value! > Date(timeIntervalSince1970: 1_700_000_000)) // Everything else is the draft's own bytes: the body, the author, the unknown key and its // inline comment, and the attachment that rode the rename. #expect(parsed.body == draftBody) #expect(parsed.author.value == "Ada Lovelace") #expect(try fixture.indexText(path).contains("project: lanework # agent overlay")) #expect(fixture.exists("\(path)/attachments/shot.png")) } @Test("A posted comment joins the thread; the draft no longer does") func postedCommentIsInTheThread() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) let thread = CommentThread.load(inCard: fixture.url(card), path: card) #expect(thread.comments.map(\.id) == [posted.id]) #expect(!thread.hasDraft) } @Test("Posting nothing refuses, naming the card") func postWithoutADraftRefuses() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let failure = writeFailure { _ = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) } #expect(failure?.operation == .postComment(title: cardTitle)) } @Test("A second post mints a second identity beside the first") func twoPostsAreTwoComments() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText(body: "one\n")) let first = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) try fixture.item("\(card)/comments/.draft", commentText(body: "two\n")) let second = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) #expect(first.id != second.id) #expect(try postedNames(fixture, inCard: card).count == 2) } } // MARK: - Editing @Suite("Comments ▸ edit") struct CommentEditTests { @Test("An edit writes the body and stamps modified — '· edited' falls out of the pair") func editStampsModified() 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()) #expect(try BoardWriter.editComment(at: fixture.url(path), body: "revised\n", cardTitle: cardTitle)) let parsed = try document(fixture, path) #expect(parsed.body == "revised\n") #expect(parsed.created.value != parsed.modified.value) let thread = CommentThread.load(inCard: fixture.url(card), path: card) #expect(thread.comments.first?.isEdited == true) } @Test("An unchanged body writes nothing") func unchangedEditWritesNothing() 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: "same\n")) let before = try fixture.indexData(path) #expect(try BoardWriter.editComment(at: fixture.url(path), body: "same\n", cardTitle: cardTitle) == false) #expect(try fixture.indexData(path) == before) } @Test("Only a posted comment is editable — never the draft, never a trashed one") func refusesTheTwoDotFolders() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) for path in ["\(card)/comments/.draft", "\(card)/comments/.trash/\(CommentIdent.one)"] { let failure = writeFailure { _ = try BoardWriter.editComment(at: fixture.url(path), body: "x", cardTitle: cardTitle) } #expect(failure?.operation == .editComment(title: cardTitle)) } } } // MARK: - Delete, restore, purge @Suite("Comments ▸ delete and purge") struct CommentDeleteTests { @Test("A delete is a move into comments/.trash that stamps and clears modified-by") func deleteMovesAndStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, "---\nschema: 1\nauthor: Ada\nmodified-by: claude\ncreated: 2026-01-01T09:00:00Z\nmodified: 2026-01-01T09:00:00Z\nkind: comment\n---\nbody\n") let id = try BoardWriter.deleteComment(at: fixture.url(path), cardTitle: cardTitle) #expect(id.rawValue == CommentIdent.one) #expect(!fixture.exists(path)) let trashed = "\(card)/comments/.trash/\(CommentIdent.one)" let parsed = try document(fixture, trashed) #expect(parsed.modified.value != parsed.created.value, "a container change stamps") #expect(parsed.modifiedBy.isMissing) #expect(parsed.author.value == "Ada", "author is content and survives even this") #expect(parsed.body == "body\n") } @Test("A deleted comment leaves the thread") func deletedIsExcluded() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-02-02T09:00:00Z")) try BoardWriter.deleteComment(at: fixture.url(commentPath(CommentIdent.one, inCard: card)), cardTitle: cardTitle) let thread = CommentThread.load(inCard: fixture.url(card), path: card) #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two]) } @Test("Restoring is the ordinary move back out, and stamps again") func restoreMovesBack() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) try BoardWriter.restoreComment( ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card), cardTitle: cardTitle ) #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card))) #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) #expect(try document(fixture, commentPath(CommentIdent.one, inCard: card)).body == "A comment body — with *markdown*.\n") } @Test("The purge removes the entries, keeps strays, and leaves the container") func purgeRemovesEntriesOnly() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) try fixture.file("\(card)/comments/.trash/notes.txt", Data("mine".utf8)) let purged = try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)) #expect(Set(purged.map(\.rawValue)) == [CommentIdent.one, CommentIdent.two]) #expect(fixture.exists("\(card)/comments/.trash")) #expect(try fixture.data("\(card)/comments/.trash/notes.txt") == Data("mine".utf8)) } @Test("A card with no thread trash purges nothing") func purgeIsTotal() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) #expect(try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)).isEmpty) } } // MARK: - The crash-residue memo @MainActor @Suite("Comments ▸ crash residue") struct CommentResidueTests { @Test("Residue left by a crashed session sweeps at the next open, and the memo clears") func residueSweeps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) let (store, _) = try makeStore(fixture) store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1)) #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) #expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success") } @Test("Content a live step still backs is not residue — the sweep asks the stack first") func aLiveStepsBackingIsNotResidue() throws { // 13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31: "`comments/.trash/` // content referenced by a live coarse step on the board stack is a step's backing, not // residue — the open-time sweep consults the stack and skips owned content". The gate is over // *steps*, not over the coarse one by name, so the shortest way to hold a live step is the // window-less delete (`deleteComment`'s `nil` window, which is the board's own stack). let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) let (store, history) = try makeStore(fixture) let cardID = ItemID(rawValue: Ident.card1) #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID)) store.sweepCommentTrashResidue(inCard: cardID) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the delete step's undo is the move back out — this is its backing") #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "and the entry no step names swept as before") history.undo() #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so ⌘Z still has something to restore") } @Test("An open with nothing to sweep rests — no bracket, no memo") func cleanOpenRests() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try makeCommentBoard(fixture) let (store, _) = try makeStore(fixture) store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1)) #expect(store.heals.memo(for: .commentTrashResidue) == nil) } @Test("The close purge and the residue sweep converge on the same disk state") func purgeAndSweepAgree() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) let (store, _) = try makeStore(fixture) store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1)) #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } } // MARK: - Undo @MainActor @Suite("Comments ▸ undo") struct CommentUndoTests { private func board() throws -> (fixture: WriterFixture, card: String) { let fixture = try WriterFixture() let card = try makeCommentBoard(fixture) return (fixture, card) } @Test("Posting registers one step, named for the 06 verb family") func postRegistersOneStep() throws { let (fixture, card) = try board() defer { fixture.tearDown() } try fixture.item("\(card)/comments/.draft", commentText()) let (store, history) = try makeStore(fixture) _ = store.postComment(inCard: ItemID(rawValue: Ident.card1)) #expect(history.canUndo) #expect(history.undoActionName == "Comment") } @Test("Undoing a post renames back to .draft; redo replays the same identity and instant") func postRoundTrip() throws { let (fixture, card) = try board() defer { fixture.tearDown() } try fixture.item("\(card)/comments/.draft", commentText(body: "drafted\n")) let (store, history) = try makeStore(fixture) let posted = store.postComment(inCard: ItemID(rawValue: Ident.card1)) let id = try #require(posted) let postedText = try fixture.indexText("\(card)/comments/\(id.rawValue)") history.undo() #expect(!fixture.exists("\(card)/comments/\(id.rawValue)")) #expect(fixture.exists("\(card)/comments/.draft")) // A rename stamps nothing: the un-posted draft carries the post's own bytes, untouched. #expect(try fixture.indexText("\(card)/comments/.draft") == postedText) history.redo() #expect(try postedNames(fixture, inCard: card) == [id.rawValue], "the same identity, not a fresh mint") #expect(try fixture.indexText("\(card)/comments/\(id.rawValue)") == postedText, "the same instant, restamped") } @Test("A .draft typed since makes the post's undo stale — it skips, it never clobbers") func postUndoSkipsWhenADraftIsBack() throws { let (fixture, card) = try board() defer { fixture.tearDown() } try fixture.item("\(card)/comments/.draft", commentText(body: "first\n")) let (store, history) = try makeStore(fixture) let id = try #require(store.postComment(inCard: ItemID(rawValue: Ident.card1))) // A new draft, the way the composer would leave one. try fixture.item("\(card)/comments/.draft", commentText(body: "second\n")) let untouched = try fixture.indexText("\(card)/comments/.draft") history.undo() #expect(try fixture.indexText("\(card)/comments/.draft") == untouched, "the new draft is not overwritten") #expect(fixture.exists("\(card)/comments/\(id.rawValue)"), "and the posted comment stays posted") #expect(!history.canUndo, "the stale step was popped") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"]) } @Test("Deleting registers one step; undo moves it back and redo moves it in") func deleteRoundTrip() throws { let (fixture, card) = try board() defer { fixture.tearDown() } let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText()) let (store, history) = try makeStore(fixture) #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1))) #expect(history.undoActionName == "Delete Comment") #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) history.undo() #expect(fixture.exists(path)) #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) #expect(try document(fixture, path).body == "A comment body — with *markdown*.\n") history.redo() #expect(!fixture.exists(path)) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) } @Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing") func purgeMakesDeleteStepsStale() throws { let (fixture, card) = try board() defer { fixture.tearDown() } try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) let (store, history) = try makeStore(fixture) #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1))) store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1)) #expect(history.canUndo, "invalidation is lazy — the stack still looks full") history.undo() #expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card))) #expect(!history.canUndo) #expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"]) } @Test("The draft save, the inline edit and the purge register nothing") func theThreeSilentOperations() throws { let (fixture, card) = try board() defer { fixture.tearDown() } try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) let (store, history) = try makeStore(fixture) let cardID = ItemID(rawValue: Ident.card1) store.saveCommentDraft(inCard: cardID, body: "typing\n") #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "revised\n")) store.purgeCommentTrash(inCard: cardID) #expect(!history.canUndo, "no byte capture in any tier — 13's rule") } @Test("Comment expectations validate disk, which is why comments need no snapshot") func expectationsReadDisk() throws { let (fixture, card) = try board() defer { fixture.tearDown() } let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText()) // Path anchors resolve to themselves — the resolver a caller with no board hands in // (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'. #expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath)) #expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath)) try FileManager.default.removeItem(at: fixture.url(path)) #expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath)) } } // MARK: - Copy boundaries @Suite("Comments ▸ copy boundaries") struct CommentCopyTests { /// A card with a full thread: one posted comment carrying a tracker key and an attachment, a /// draft, and one comment sitting in the thread's trash. private func threadedCard(_ fixture: WriterFixture, in lane: String, card: String) throws -> String { let path = "\(lane)/\(card)" try fixture.item(path, Item.rich(order: "1024", title: "Fix login")) try fixture.item("\(path)/comments/\(CommentIdent.one)", commentText(remote: "gitea#42")) try fixture.file("\(path)/comments/\(CommentIdent.one)/attachments/shot.png", Data("png".utf8)) try fixture.item("\(path)/comments/.draft", commentText(body: "unposted\n")) try fixture.item("\(path)/comments/.trash/\(CommentIdent.two)", commentText()) return path } @Test("An item-level copy carries the thread, remints it, severs remote, and strips the trash") func itemCopyMatrix() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) let copy = try BoardWriter.copyItem( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url(Ident.lane2), order: 1024, stamps: .fork ) let copied = "\(Ident.lane2)/\(copy.rawValue)" // The thread came, reminted. let names = try postedNames(fixture, inCard: copied) #expect(names.count == 1) #expect(names[0] != CommentIdent.one, "comment folders remint like every copied folder") #expect(names[0] == names[0].lowercased()) // The contract applied at comment depth. let parsed = try document(fixture, "\(copied)/comments/\(names[0])") #expect(parsed.value(for: FrontmatterKeys.remote) == nil, "the tracker claim is severed") #expect(parsed.author.value == "Ada Lovelace") #expect(parsed.created.value != nil, ".fork keeps created") #expect(fixture.exists("\(copied)/comments/\(names[0])/attachments/shot.png")) // The draft carries, fork-lossless; the trash does not travel. #expect(try document(fixture, "\(copied)/comments/.draft").body == "unposted\n") #expect(!fixture.exists("\(copied)/comments/.trash")) // The source is untouched, thread trash included. #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.two)")) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)")) } @Test("A copied lane's cards' threads are reminted too — arbitrary depth") func laneCopyReachesCommentDepth() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) let copy = try BoardWriter.copyItem( at: fixture.url(Ident.lane1), toParent: fixture.root, order: 4096, stamps: .fork ) let cards = try fixture.entryNames(copy.rawValue).filter { IntegrityRules.isIdentityShaped($0) } #expect(cards.count == 1) let copiedCard = "\(copy.rawValue)/\(cards[0])" let comments = try postedNames(fixture, inCard: copiedCard) #expect(comments.count == 1) #expect(comments[0] != CommentIdent.one) #expect(try document(fixture, "\(copiedCard)/comments/\(comments[0])").value(for: FrontmatterKeys.remote) == nil) #expect(!fixture.exists("\(copiedCard)/comments/.trash")) } /// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and /// § Enhanced schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out this suite used to /// pin): "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card /// or lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not /// a load". /// /// The board still loads with this comment on it — that is the other test in this file — and it /// is the gesture that refuses. Whole, and with nothing materialized: a partial copy is the one /// outcome the transaction rule exists to prevent. @Test("A comment nobody can stamp refuses the whole copy, and nothing is materialized") func brokenCommentRefusesACopy() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) // Readable, uneditable — the shape that refuses a copy at card level, now at comment depth. try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable) var thrown: BoardWriteError? do { _ = try BoardWriter.copyItem( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url(Ident.lane2), order: 1024, stamps: .fork ) } catch { thrown = error } let error = try #require(thrown) if case .uneditableFrontmatter = error.reason {} else { Issue.record("expected an uneditable-frontmatter refusal, got \(error.reason)") } #expect(error.path.hasSuffix("comments/\(CommentIdent.one)/index.md"), "the path points at the annotation, which is where the fix is") // Nothing landed, and the source is untouched. #expect(try fixture.entryNames(Ident.lane2).filter { IntegrityRules.isIdentityShaped($0) }.isEmpty) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)") == Item.uneditable) } /// The other side of "load-scoped": the very same board **loads**, and its card window's thread /// read tolerates the annotation. Only the copy gesture refuses. @Test("The same broken comment never refuses the board") func brokenCommentNeverRefusesTheBoard() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } 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: "Fix login")) try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable) let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card1]) } @Test("Template instantiation is born-today at comment depth too") func instantiationRestampsComments() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) let copy = try BoardWriter.copyItem( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url(Ident.lane2), order: 1024, stamps: .born ) let copied = "\(Ident.lane2)/\(copy.rawValue)" let names = try postedNames(fixture, inCard: copied) let created = try #require(document(fixture, "\(copied)/comments/\(names[0])").created.value) #expect(created.timeIntervalSinceNow > -60, ".born restamps created") } @Test("A whole-board fork carries the thread verbatim — but never its trash") func wholeBoardForkCarriesVerbatim() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) let destination = fixture.root.deletingLastPathComponent() .appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: destination) } try BoardTreeCopy.createDirectory(at: destination) try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false }) let thread = destination .appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1) .appendingPathComponent("comments") // GUIDs kept, tracker keys kept — a fork is a fork. let forked = thread.appendingPathComponent(CommentIdent.one).appendingPathComponent("index.md") #expect(FileManager.default.fileExists(atPath: forked.path)) let text = try String(decoding: Data(contentsOf: forked), as: UTF8.self) #expect(text.contains("remote: gitea#42")) // The draft too. #expect(FileManager.default.fileExists( atPath: thread.appendingPathComponent(".draft").appendingPathComponent("index.md").path )) // The thread trash, never. #expect(!FileManager.default.fileExists(atPath: thread.appendingPathComponent(".trash").path)) } @Test("The board's own .trash is untouched by the comment strip") func theBoardTrashIsNotTheThreadTrash() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Trashed")) let destination = fixture.root.deletingLastPathComponent() .appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: destination) } try BoardTreeCopy.createDirectory(at: destination) try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false }) #expect(FileManager.default.fileExists( atPath: destination.appendingPathComponent(".trash/\(Ident.card4)/index.md").path )) } @Test("A trashed card carries its thread — and the trash interplay costs nothing") func trashedCardsCarryTheirThread() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root) let trashedCard = ".trash/\(Ident.card1)" #expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)")) #expect(fixture.exists("\(trashedCard)/comments/.draft")) #expect(fixture.exists("\(trashedCard)/comments/.trash/\(CommentIdent.two)"), "a move carries everything") let thread = CommentThread.load(inCard: fixture.url(trashedCard), path: trashedCard) #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one]) // Restoring is the ordinary move back, thread intact. _ = try BoardWriter.moveItem( at: fixture.url(trashedCard), toParent: fixture.url(Ident.lane1), sourceBoardRoot: fixture.root, destinationBoardRoot: fixture.root, order: 1024 ) #expect(fixture.exists("\(card)/comments/\(CommentIdent.one)")) } @Test("The purge takes the whole card, thread and all") func purgeTakesTheThread() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)") try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card1)"), inBoard: fixture.root) #expect(!fixture.exists(".trash/\(Ident.card1)")) } }