Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
396 lines
16 KiB
Swift
396 lines
16 KiB
Swift
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'"])
|
|
}
|
|
}
|