import Foundation import Testing @testable import Kanban /// **What a foreign comment change says out loud** — 10-accessibility.md ▸ Comments ("Foreign comment /// arrivals announce path-shaped ('New comment on '⟨card⟩'')"), 01-storage-format.md ▸ Enhanced schema /// (the path shape, and the verb family 06 gains with the feature). /// /// Three layers, three suites, because the rule is three separate claims stacked: /// /// 1. **What changed** — `CommentThreadChanges`, a pure comparison of two threads. /// 2. **Whose it was** — the `EchoLedger`'s comment receipts, read through `CommentPath.classify`; /// the app's own writes never announce. /// 3. **What that says** — `BoardAnnouncer.commentSpeech(for:onCard:)` over `AccessibilityPhrases`. /// /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `CommentIdent`, /// `commentText` and `makeCommentBoard` from `CommentThreadTests.swift`. // MARK: - Fixtures private func comment(_ id: String, body: String) -> Kanban.Comment { Kanban.Comment( id: ItemID(rawValue: id), schema: .valid(1), author: .missing, created: .missing, modified: .missing, modifiedBy: .missing, attachments: [], document: FrontmatterDocument(body: body) ) } private func thread(_ comments: [Kanban.Comment]) -> CommentThread { CommentThread(comments: comments, strays: [], defects: [], hasDraft: false) } // MARK: - What changed @Suite("Comment announcements ▸ the thread diff") struct CommentThreadChangesTests { @Test("An arrival, an edit and a departure, each in its own bucket") func threeBuckets() { let old = thread([comment(CommentIdent.one, body: "one"), comment(CommentIdent.two, body: "two")]) let new = thread([comment(CommentIdent.one, body: "one, amended"), comment(CommentIdent.three, body: "three")]) let changes = CommentThreadChanges.between(old, new) #expect(changes.arrived == [ItemID(rawValue: CommentIdent.three)]) #expect(changes.edited == [ItemID(rawValue: CommentIdent.one)]) #expect(changes.deleted == [ItemID(rawValue: CommentIdent.two)]) } @Test("An unchanged thread is empty — the overwhelmingly common reload") func unchangedIsEmpty() { let same = thread([comment(CommentIdent.one, body: "one")]) #expect(CommentThreadChanges.between(same, same).isEmpty) } @Test("An attachment landing on a comment is not a change here — the comparison is index.md") func attachmentsAreNotEdits() { let before = comment(CommentIdent.one, body: "one") let after = Kanban.Comment( id: before.id, schema: before.schema, author: before.author, created: before.created, modified: before.modified, modifiedBy: before.modifiedBy, attachments: ["shot.png"], document: before.document ) #expect(CommentThreadChanges.between(thread([before]), thread([after])).isEmpty) } @Test("Vouched changes are removed one at a time, never all-or-nothing") func exclusionIsPerComment() { let changes = CommentThreadChanges( arrived: [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)], edited: [ItemID(rawValue: CommentIdent.three)] ) // The app posted one of the two arrivals; an agent filed the other in the same window. let foreign = changes.excluding([ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)]) #expect(foreign.arrived == [ItemID(rawValue: CommentIdent.two)]) #expect(foreign.edited.isEmpty) } } // MARK: - Whose it was @MainActor @Suite("Comment announcements ▸ the ledger's comment receipts") struct CommentReceiptTests { @Test("A posted comment is vouched for, and the receipts are retired on the way out") func postIsVouched() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let store = try BoardStore(rootURL: fixture.root) let cardID = ItemID(rawValue: Ident.card1) store.saveCommentDraft(inCard: cardID, body: "A remark.") let posted = try #require(store.postComment(inCard: cardID)) #expect(store.vouchedComments(inCard: cardID) == [posted]) // One write, one echo: a second reload finds nothing vouching for the same comment. #expect(store.vouchedComments(inCard: cardID).isEmpty) #expect(fixture.exists("\(card)/comments/\(posted.rawValue)")) } @Test("An inline edit's save is vouched for — the debounce writes without a re-read") func editIsVouched() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n")) let store = try BoardStore(rootURL: fixture.root) let cardID = ItemID(rawValue: Ident.card1) #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "after")) #expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)]) } @Test("A delete is vouched for at both ends of its move — the thread's and the trash's") func deleteIsVouched() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n")) let store = try BoardStore(rootURL: fixture.root) let cardID = ItemID(rawValue: Ident.card1) #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID)) #expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)]) #expect(store.echoes.outstandingReceipts == 0) } @Test("A draft save vouches for nothing in the thread — a draft is not in it") func draftVouchesForNothing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try makeCommentBoard(fixture) let store = try BoardStore(rootURL: fixture.root) let cardID = ItemID(rawValue: Ident.card1) store.saveCommentDraft(inCard: cardID, body: "half a thought") #expect(store.vouchedComments(inCard: cardID).isEmpty) // Retired all the same: a receipt nothing ever collects is a receipt that outlives its write. #expect(store.echoes.outstandingReceipts == 0) } @Test("A comment receipt never makes its card's own edit look foreign") func commentReceiptsDoNotPoisonTheCard() throws { // The regression this guards: a card's footprint used to resolve *every* receipt under its // folder against its attachment listing, so a comment's `index.md` — which no snapshot can // observe — read as absent, failed its receipt, and classified the card foreign. The user's // own next title or body edit would then have been announced as somebody else's. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let cardFolder = fixture.url(card) let old = try BoardLoader.load(boardRoot: fixture.root).model try BoardWriter.writeBody(inItemFolder: cardFolder, body: "An amended body.") let new = try BoardLoader.load(boardRoot: fixture.root).model let card1 = try #require(new.lanes.first?.cards.first) let ledger = EchoLedger() ledger.recordWrite( atPath: EchoLedger.key(cardFolder.appendingPathComponent(BoardLoader.indexFileName)), hash: EchoLedger.hash(of: card1.document.serialized()) ) ledger.recordWrite( atPath: EchoLedger.key( CommentThread .commentFolder(ItemID(rawValue: CommentIdent.one), inCard: cardFolder) .appendingPathComponent(BoardLoader.indexFileName) ), hash: "a hash no snapshot can ever produce" ) let diff = BoardDiff.between(old, new, includingTrash: false) let verdicts = ledger.verdicts(from: old, to: new, diff: diff, includingTrash: false) #expect(verdicts.foreignItems.isEmpty) #expect(!verdicts.foreign.boardChanged) // And the comment receipt is still there for the card window to collect. #expect(ledger.outstandingReceipts == 1) } } // MARK: - What it says @Suite("Comment announcements ▸ the sentence") struct CommentSpeechTests { private let card = "Fix login" @Test("An arrival is 10's own sentence") func arrivalSpeaks() { let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)]) #expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'") } @Test("Edits and deletions speak in 06's verb family") func theFamilySpeaks() { #expect( BoardAnnouncer.commentSpeech( for: CommentThreadChanges(edited: [ItemID(rawValue: CommentIdent.one)]), onCard: card ) == "Edit comment on 'Fix login'" ) #expect( BoardAnnouncer.commentSpeech( for: CommentThreadChanges(deleted: [ItemID(rawValue: CommentIdent.one)]), onCard: card ) == "Delete comment on 'Fix login'" ) } @Test("Counts fold, like every other count in the app") func countsFold() { let two = [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)] #expect( BoardAnnouncer.commentSpeech(for: CommentThreadChanges(arrived: two), onCard: card) == "2 new comments on 'Fix login'" ) #expect( BoardAnnouncer.commentSpeech(for: CommentThreadChanges(edited: two), onCard: card) == "2 comments edited on 'Fix login'" ) #expect( BoardAnnouncer.commentSpeech(for: CommentThreadChanges(deleted: two), onCard: card) == "2 comments deleted on 'Fix login'" ) } @Test("Arrivals lead — one polite sentence, chosen by precedence rather than concatenated") func arrivalsLead() { let changes = CommentThreadChanges( arrived: [ItemID(rawValue: CommentIdent.one)], edited: [ItemID(rawValue: CommentIdent.two)], deleted: [ItemID(rawValue: CommentIdent.three)] ) #expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'") } @Test("An untitled card wears the placeholder the board wears") func untitledCard() { let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)]) #expect(BoardAnnouncer.commentSpeech(for: changes, onCard: nil) == "New comment on 'Untitled'") } @Test("Nothing changed is silence") func nothingIsSilence() { #expect(BoardAnnouncer.commentSpeech(for: CommentThreadChanges(), onCard: card) == nil) } } // MARK: - The pane's own voice @MainActor @Suite("Comment announcements ▸ the pane") struct CommentPaneAnnouncementTests { /// A pane wired to a real store on a real board — the only way the receipts half is honest, since /// what silences an echo is a write having actually happened. /// /// The store rides on the recorder rather than being returned beside it, and that is not tidiness: /// `CardWindowHost.configureComments` captures the store **weakly** (a save landing after the board /// window has gone must write nothing), so a test that let it go out of scope would be driving a /// pane whose every seam answers "vanished" — which looks exactly like a bug in the pane. private func makePane(_ fixture: WriterFixture) throws -> (CardComments, Spoken) { let store = try BoardStore(rootURL: fixture.root) let comments = CardComments() comments.isEditable = true comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") comments.cardTitle = "Fix login" CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) let spoken = Spoken(store: store) comments.announce = { spoken.record($0) } return (comments, spoken) } /// What the pane said, and the store it said it about — held here so the weak seams stay wired. @MainActor final class Spoken { let store: BoardStore private(set) var phrases: [String] = [] init(store: BoardStore) { self.store = store } func record(_ phrase: String?) { guard let phrase else { return } phrases.append(phrase) } } @Test("Opening a card with a thread announces nothing — the window is not a change") func openingIsSilent() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "Already here.\n")) let (comments, spoken) = try makePane(fixture) comments.open() #expect(comments.thread.comments.count == 1) #expect(spoken.phrases.isEmpty) } @Test("A foreign arrival speaks path-shaped, naming the card") func foreignArrivalSpeaks() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let (comments, spoken) = try makePane(fixture) comments.open() try fixture.item("\(card)/comments/\(CommentIdent.two)", commentText(body: "An agent's remark.\n")) comments.reload() #expect(spoken.phrases == ["New comment on 'Fix login'"]) } @Test("Our own post is silent — the receipt vouches for it") func ownPostIsSilent() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try makeCommentBoard(fixture) let (comments, spoken) = try makePane(fixture) comments.open() comments.composer.edited("A remark of our own.") comments.composer.flush() #expect(comments.composer.postNow() != nil) comments.reload() #expect(comments.thread.comments.count == 1) #expect(spoken.phrases.isEmpty) } @Test("Our own inline edit is silent, even though nothing re-read the thread in between") func ownEditIsSilent() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n")) let (comments, spoken) = try makePane(fixture) let store = spoken.store comments.open() // The debounced save the inline session makes, without a reload of its own — so the *next* // reload is the first to see it, which is exactly the case the ledger has to cover. #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1), body: "after")) comments.reload() #expect(comments.thread.comments.first?.body == "after") #expect(spoken.phrases.isEmpty) } @Test("Our own delete is silent") func ownDeleteIsSilent() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n")) let (comments, spoken) = try makePane(fixture) comments.open() comments.delete(ItemID(rawValue: CommentIdent.one)) #expect(comments.thread.comments.isEmpty) #expect(spoken.phrases.isEmpty) } @Test("A foreign arrival alongside our own post announces the half nobody vouched for") func mixedReloadSpeaksTheForeignHalf() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let (comments, spoken) = try makePane(fixture) let store = spoken.store comments.open() store.saveCommentDraft(inCard: ItemID(rawValue: Ident.card1), body: "Ours.") #expect(store.postComment(inCard: ItemID(rawValue: Ident.card1)) != nil) try fixture.item("\(card)/comments/\(CommentIdent.three)", commentText(body: "Theirs.\n")) comments.reload() #expect(comments.thread.comments.count == 2) #expect(spoken.phrases == ["New comment on 'Fix login'"]) } }