Comments, phase 3 — search, the thread find, announcements, and a11y
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
This commit is contained in:
@@ -277,6 +277,58 @@ struct AccessibilityPhrasesTests {
|
||||
#expect(AccessibilityPhrases.readOnlyLockCleared == "The board is editable again")
|
||||
#expect(AccessibilityPhrases.reloadBreakageCleared == "The board is loading again")
|
||||
}
|
||||
|
||||
// MARK: - The comments pane
|
||||
|
||||
/// 10-accessibility.md ▸ Comments: "the pane is a labeled container ('Comments, N')".
|
||||
@Test("The pane's container names itself and its count")
|
||||
func commentsContainer() {
|
||||
#expect(AccessibilityPhrases.commentsContainerLabel(count: 3) == "Comments, 3")
|
||||
#expect(
|
||||
AccessibilityPhrases.commentsContainerLabel(count: 0) == "Comments, 0",
|
||||
"a comment-less card still shows the pane — the invitation is the point"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A comment count folds its plural, like every other count in the app")
|
||||
func commentCounts() {
|
||||
#expect(AccessibilityPhrases.commentCount(1) == "1 comment")
|
||||
#expect(AccessibilityPhrases.commentCount(4) == "4 comments")
|
||||
}
|
||||
|
||||
/// The flattened element: the author line is what it *is*, the body is what it holds.
|
||||
@Test("A comment's label is its author line, and its value is its body")
|
||||
func commentElement() {
|
||||
#expect(AccessibilityPhrases.commentLabel(authorLine: "Ada Lovelace · 1 Jan 2026") == "Ada Lovelace · 1 Jan 2026")
|
||||
#expect(AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 0) == "A remark.")
|
||||
#expect(
|
||||
AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 2) == "A remark., 2 attachments"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A comment with nothing to attribute is still a named element")
|
||||
func unattributedComment() {
|
||||
// "Unattributed" is the absence of a name, never a name to speak (`CommentAuthorLine`) — but an
|
||||
// unlabeled element is an audit failure, so the fallback is the noun itself.
|
||||
#expect(AccessibilityPhrases.commentLabel(authorLine: nil) == "Comment")
|
||||
#expect(AccessibilityPhrases.commentValue(body: " \n", attachments: 0) == "")
|
||||
}
|
||||
|
||||
/// The custom actions must be the *same* strings as the context menu's rows (10 ▸ Comments), so a
|
||||
/// user who has learned the pointer inventory hears the same three words from the rotor.
|
||||
@Test("The comment's custom actions are its context menu's rows")
|
||||
func commentActions() {
|
||||
#expect(AccessibilityPhrases.commentEditAction == "Edit")
|
||||
#expect(AccessibilityPhrases.commentDeleteAction == "Delete")
|
||||
#expect(AccessibilityPhrases.commentRevealAction == "Reveal in Finder")
|
||||
}
|
||||
|
||||
@Test("The composer, the sort control and the paperclip are labeled")
|
||||
func commentControls() {
|
||||
#expect(AccessibilityPhrases.commentComposerLabel == "Add a comment")
|
||||
#expect(AccessibilityPhrases.commentSortLabel == "Sort")
|
||||
#expect(AccessibilityPhrases.commentAttachFilesLabel == "Attach Files")
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct identities for the digest cases, which care only about *counts* — the diff's own suite
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
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'"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The card window's ⌘F over the comments pane** — 05-card-window.md ▸ Preview:
|
||||
///
|
||||
/// > Edit ▸ Find (⌘F) is find-in-text here — the standard find bar over the focused surface (Preview's
|
||||
/// > selectable text, the Edit editor, raw source — and the comments pane, where it searches the whole
|
||||
/// > rendered thread, `.draft` excluded; the composer and an inline comment edit are their own focused
|
||||
/// > text surfaces with the editor's ordinary find).
|
||||
///
|
||||
/// Two claims and therefore two halves: **where the key goes** (a pure routing rule over the pane's
|
||||
/// focus, `CardWindowFind`), and **what the find does once it is there** (a pure search over the
|
||||
/// rendered thread plus a session that steps through it, `CommentThreadSearch` / `CommentThreadFind`).
|
||||
///
|
||||
/// The thread itself is never `.draft` or `comments/.trash/` here for free — those are excluded from
|
||||
/// the listing by the loader (`CommentThreadTests`), so a find that searches "the thread" cannot see
|
||||
/// them by construction.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func row(_ id: String, _ text: String) -> CommentThreadRow {
|
||||
CommentThreadRow(id: ItemID(rawValue: id), text: text)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Routing
|
||||
|
||||
@Suite("Comment find ▸ where ⌘F goes")
|
||||
struct CardWindowFindRouteTests {
|
||||
|
||||
@Test("A focused comment routes to the thread; a focused authoring surface to its own editor")
|
||||
func focusDecides() {
|
||||
#expect(CardWindowFind.route(paneFocus: .thread, isThreadFindShowing: false, hasBody: true) == .thread)
|
||||
#expect(CardWindowFind.route(paneFocus: .authoring, isThreadFindShowing: false, hasBody: true) == .authoring)
|
||||
}
|
||||
|
||||
@Test("With the pane unfocused it is the body's find, exactly as before the pane existed")
|
||||
func bodyIsTheDefault() {
|
||||
#expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: false, hasBody: true) == .body)
|
||||
#expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: false, hasBody: false) == nil)
|
||||
}
|
||||
|
||||
@Test("An open find bar outranks an absent focus — a second ⌘F returns to the search field")
|
||||
func theOpenBarKeepsTheKey() {
|
||||
// Raising the bar takes the keyboard out of the thread and into the bar's own field, so the
|
||||
// pane reports no focus; without this clause the next ⌘F would fall through to the body.
|
||||
#expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: true, hasBody: true) == .thread)
|
||||
}
|
||||
|
||||
@Test("An authoring surface outranks even an open bar — the user is typing in an editor")
|
||||
func authoringWins() {
|
||||
#expect(CardWindowFind.route(paneFocus: .authoring, isThreadFindShowing: true, hasBody: true) == .authoring)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The search
|
||||
|
||||
@Suite("Comment find ▸ the search")
|
||||
struct CommentThreadSearchTests {
|
||||
|
||||
@Test("Matches are in reading order: rows in order, hits within a row left to right")
|
||||
func readingOrder() {
|
||||
let matches = CommentThreadSearch.matches(
|
||||
in: [row(CommentIdent.one, "kestrel and kestrel"), row(CommentIdent.two, "a kestrel")],
|
||||
query: "kestrel"
|
||||
)
|
||||
#expect(matches.count == 3)
|
||||
#expect(matches[0] == CommentThreadMatch(comment: ItemID(rawValue: CommentIdent.one), range: NSRange(location: 0, length: 7)))
|
||||
#expect(matches[1].comment == ItemID(rawValue: CommentIdent.one))
|
||||
#expect(matches[1].range.location == 12)
|
||||
#expect(matches[2].comment == ItemID(rawValue: CommentIdent.two))
|
||||
}
|
||||
|
||||
@Test("The comparison folds case and diacritics — the board query's own rule")
|
||||
func folding() {
|
||||
let matches = CommentThreadSearch.matches(in: [row(CommentIdent.one, "A Résumé")], query: "resume")
|
||||
#expect(matches.count == 1)
|
||||
// The range is in the *unfolded* text, which is what makes the highlight land on what is drawn.
|
||||
#expect(matches[0].range == NSRange(location: 2, length: 6))
|
||||
}
|
||||
|
||||
@Test("Overlapping occurrences are one hit — NSTextFinder's answer, and the body's ⌘F's")
|
||||
func overlapping() {
|
||||
// Two finds in one window must not disagree about how many times "aa" occurs in "aaa".
|
||||
#expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "aaa")], query: "aa").count == 1)
|
||||
#expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "aaaa")], query: "aa").count == 2)
|
||||
}
|
||||
|
||||
@Test("An empty query finds nothing — the bar opens empty and says nothing")
|
||||
func emptyQuery() {
|
||||
#expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "anything")], query: "").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Stepping wraps in both directions — which is what makes it one find across rows")
|
||||
func stepping() {
|
||||
#expect(CommentThreadSearch.step(from: 0, count: 3, forward: true) == 1)
|
||||
#expect(CommentThreadSearch.step(from: 2, count: 3, forward: true) == 0)
|
||||
#expect(CommentThreadSearch.step(from: 0, count: 3, forward: false) == 2)
|
||||
#expect(CommentThreadSearch.step(from: 0, count: 1, forward: true) == 0)
|
||||
#expect(CommentThreadSearch.step(from: 0, count: 0, forward: true) == 0)
|
||||
}
|
||||
|
||||
@Test("The counter reads as a find bar's does, and says so when there is nothing")
|
||||
func status() {
|
||||
#expect(CommentThreadSearch.status(index: 0, count: 3, query: "k") == "1 of 3")
|
||||
#expect(CommentThreadSearch.status(index: 2, count: 3, query: "k") == "3 of 3")
|
||||
#expect(CommentThreadSearch.status(index: 0, count: 0, query: "k") == "No matches")
|
||||
#expect(CommentThreadSearch.status(index: 0, count: 0, query: "") == "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The session
|
||||
|
||||
@MainActor
|
||||
@Suite("Comment find ▸ the session")
|
||||
struct CommentThreadFindTests {
|
||||
|
||||
private let pointSize: CGFloat = 13
|
||||
|
||||
@Test("⌘F raises the bar and asks for the keyboard, every time")
|
||||
func invoking() {
|
||||
let find = CommentThreadFind()
|
||||
#expect(!find.isShowing)
|
||||
|
||||
find.invoke()
|
||||
#expect(find.isShowing)
|
||||
#expect(find.focusRequests == 1)
|
||||
|
||||
// A second press is a re-focus, not a dismissal — `FindCommand`'s "focus, not toggle" rule.
|
||||
find.invoke()
|
||||
#expect(find.isShowing)
|
||||
#expect(find.focusRequests == 2)
|
||||
}
|
||||
|
||||
@Test("Done keeps the query — re-opening lands on the search the user was running")
|
||||
func dismissKeepsTheQuery() {
|
||||
let find = CommentThreadFind()
|
||||
find.invoke()
|
||||
find.setThread([comment(CommentIdent.one, body: "kestrel")], pointSize: 13)
|
||||
find.query = "kestrel"
|
||||
#expect(find.matches(in: ItemID(rawValue: CommentIdent.one)).count == 1)
|
||||
|
||||
find.dismiss()
|
||||
#expect(!find.isShowing)
|
||||
#expect(find.query == "kestrel")
|
||||
// The marks come off the thread with the bar — a dismissed find must not leave a highlighted
|
||||
// thread behind, even though the search itself is remembered.
|
||||
#expect(find.matches(in: ItemID(rawValue: CommentIdent.one)).isEmpty)
|
||||
#expect(find.currentMatch == nil)
|
||||
}
|
||||
|
||||
@Test("It searches the *rendered* thread — markup is not what the reader sees, so it is not searched")
|
||||
func searchesRenderedText() {
|
||||
let find = CommentThreadFind()
|
||||
find.setThread([comment(CommentIdent.one, body: "A **bold** remark")], pointSize: pointSize)
|
||||
|
||||
find.query = "bold"
|
||||
#expect(find.matches.count == 1)
|
||||
// The asterisks are markup the renderer consumed; a user looking at the row cannot see them.
|
||||
find.query = "**bold**"
|
||||
#expect(find.matches.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Every comment is searched, mounted or not — the rows come from the thread, not the views")
|
||||
func spansTheWholeThread() {
|
||||
let find = CommentThreadFind()
|
||||
find.invoke()
|
||||
find.setThread(
|
||||
[
|
||||
comment(CommentIdent.one, body: "first kestrel"),
|
||||
comment(CommentIdent.two, body: "nothing here"),
|
||||
comment(CommentIdent.three, body: "second kestrel")
|
||||
],
|
||||
pointSize: pointSize
|
||||
)
|
||||
find.query = "kestrel"
|
||||
|
||||
#expect(find.matches.count == 2)
|
||||
#expect(find.matches.map(\.comment) == [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)])
|
||||
#expect(find.matches(in: ItemID(rawValue: CommentIdent.two)).isEmpty)
|
||||
#expect(find.matches(in: ItemID(rawValue: CommentIdent.three)).count == 1)
|
||||
}
|
||||
|
||||
@Test("Next crosses rows, and wraps — one find, not one per comment")
|
||||
func steppingCrossesRows() {
|
||||
let find = CommentThreadFind()
|
||||
find.invoke()
|
||||
find.setThread(
|
||||
[comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel")],
|
||||
pointSize: pointSize
|
||||
)
|
||||
find.query = "kestrel"
|
||||
|
||||
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.one))
|
||||
find.step(forward: true)
|
||||
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.two))
|
||||
find.step(forward: true)
|
||||
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.one))
|
||||
find.step(forward: false)
|
||||
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.two))
|
||||
}
|
||||
|
||||
@Test("A new query is a new search, so it starts at the first hit")
|
||||
func queryChangeResets() {
|
||||
let find = CommentThreadFind()
|
||||
find.setThread(
|
||||
[comment(CommentIdent.one, body: "kestrel kestrel kestrel")],
|
||||
pointSize: pointSize
|
||||
)
|
||||
find.query = "kestrel"
|
||||
find.step(forward: true)
|
||||
find.step(forward: true)
|
||||
#expect(find.status == "3 of 3")
|
||||
|
||||
find.query = "kestre"
|
||||
#expect(find.status == "1 of 3")
|
||||
}
|
||||
|
||||
@Test("A thread re-read under an open bar keeps the user's place")
|
||||
func reloadPreservesPosition() {
|
||||
let find = CommentThreadFind()
|
||||
find.setThread(
|
||||
[comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel")],
|
||||
pointSize: pointSize
|
||||
)
|
||||
find.query = "kestrel"
|
||||
find.step(forward: true)
|
||||
#expect(find.status == "2 of 2")
|
||||
|
||||
// An agent files a third comment while the user is stepping through the search.
|
||||
find.setThread(
|
||||
[
|
||||
comment(CommentIdent.one, body: "kestrel"),
|
||||
comment(CommentIdent.two, body: "kestrel"),
|
||||
comment(CommentIdent.three, body: "kestrel")
|
||||
],
|
||||
pointSize: pointSize
|
||||
)
|
||||
#expect(find.status == "2 of 3")
|
||||
}
|
||||
|
||||
@Test("A re-read that removes the matches clamps rather than pointing past the end")
|
||||
func reloadClamps() {
|
||||
let find = CommentThreadFind()
|
||||
find.invoke()
|
||||
find.setThread([comment(CommentIdent.one, body: "kestrel kestrel")], pointSize: pointSize)
|
||||
find.query = "kestrel"
|
||||
find.step(forward: true)
|
||||
|
||||
find.setThread([comment(CommentIdent.one, body: "a falcon instead")], pointSize: pointSize)
|
||||
#expect(find.matches.isEmpty)
|
||||
#expect(find.currentMatch == nil)
|
||||
#expect(find.status == "No matches")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The pane's routing, end to end
|
||||
|
||||
@MainActor
|
||||
@Suite("Comment find ▸ the pane's focus")
|
||||
struct CommentPaneFindRoutingTests {
|
||||
|
||||
@Test("A focused comment sends ⌘F to the thread's bar")
|
||||
func threadFocusRaisesTheBar() {
|
||||
let comments = CardComments()
|
||||
comments.focusEntered(.thread)
|
||||
|
||||
#expect(comments.invokeFind(hasBody: true))
|
||||
#expect(comments.find.isShowing)
|
||||
}
|
||||
|
||||
@Test("A focused authoring surface sends ⌘F to that editor's own find bar")
|
||||
func authoringFocusRunsTheEditorsFind() {
|
||||
let comments = CardComments()
|
||||
var editorFinds = 0
|
||||
comments.focusEntered(.authoring) { editorFinds += 1 }
|
||||
|
||||
#expect(comments.invokeFind(hasBody: true))
|
||||
#expect(editorFinds == 1)
|
||||
#expect(!comments.find.isShowing)
|
||||
}
|
||||
|
||||
@Test("With nothing in the pane focused, ⌘F is declined and the body keeps it")
|
||||
func unfocusedDeclines() {
|
||||
let comments = CardComments()
|
||||
#expect(!comments.invokeFind(hasBody: true))
|
||||
#expect(!comments.find.isShowing)
|
||||
}
|
||||
|
||||
@Test("A late resignation from a surface that no longer holds focus is ignored")
|
||||
func lateResignationIsIgnored() {
|
||||
// AppKit resigns the outgoing responder before the incoming one becomes first, but the
|
||||
// guard covers the case where it does not: clicking from a comment into the composer must
|
||||
// not leave the pane reporting no focus at all.
|
||||
let comments = CardComments()
|
||||
comments.focusEntered(.thread)
|
||||
comments.focusEntered(.authoring) {}
|
||||
comments.focusLeft(.thread)
|
||||
|
||||
#expect(comments.paneFocus == .authoring)
|
||||
}
|
||||
|
||||
@Test("Losing focus for real clears it, and the editor's find goes with it")
|
||||
func focusLeftClears() {
|
||||
let comments = CardComments()
|
||||
comments.focusEntered(.authoring) {}
|
||||
comments.focusLeft(.authoring)
|
||||
|
||||
#expect(comments.paneFocus == nil)
|
||||
#expect(!comments.invokeFind(hasBody: true))
|
||||
}
|
||||
|
||||
@Test("Find Next and Find Previous validate on the thread's bar alone")
|
||||
func steppingRowsValidate() {
|
||||
// The two other finds in this window are `NSTextFinder`'s, and `NSTextView` answers ⌘G through
|
||||
// the responder chain — an enabled menu item would fire first and break the stepping it exists
|
||||
// to provide (`FindSteppingCommands`).
|
||||
#expect(!FindSteppingCommands.isEnabled(nil))
|
||||
|
||||
let comments = CardComments()
|
||||
#expect(!FindSteppingCommands.isEnabled(comments))
|
||||
|
||||
comments.focusEntered(.thread)
|
||||
_ = comments.invokeFind(hasBody: true)
|
||||
#expect(FindSteppingCommands.isEnabled(comments))
|
||||
|
||||
comments.find.dismiss()
|
||||
#expect(!FindSteppingCommands.isEnabled(comments))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Board search's reach into comment bodies** — 04-interactions.md ▸ Search, re-ruled 2026-07-29:
|
||||
///
|
||||
/// > comment bodies join when comments ship — via a search-owned transient comment index, never the
|
||||
/// > snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies
|
||||
/// > (`.draft` and `comments/.trash/` excluded), kept fresh … while a query is active and discarded
|
||||
/// > when it clears — the board walk stays O(cards), 01's window-scoped read untouched.
|
||||
///
|
||||
/// Four claims, and each of them gets a suite: what the sweep reads (and what it must not), how a
|
||||
/// comment match reaches the one predicate every board surface filters through, what happens to the
|
||||
/// index when the query clears, and the asynchrony — the first keystroke's field-only answer, and the
|
||||
/// refinement that lands afterwards.
|
||||
///
|
||||
/// The boards are **real loads off real temp trees**, like every other suite that touches storage:
|
||||
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`, `CommentIdent` and
|
||||
/// `commentText` from `CommentThreadTests.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// Two lanes, three cards, and comments only where a test needs them:
|
||||
///
|
||||
/// | card | title | body | comments |
|
||||
/// |---|---|---|---|
|
||||
/// | `card1` | Fix login | (no "kestrel") | one, mentioning a kestrel |
|
||||
/// | `card2` | Kestrel plans | (title match) | none |
|
||||
/// | `card3` | Ship it | (no match) | none |
|
||||
///
|
||||
/// So "kestrel" matches `card2` by its title with no index at all, and `card1` **only** through its
|
||||
/// thread — which is the case the whole ruling is about.
|
||||
private func makeSearchBoard(_ fixture: WriterFixture) throws {
|
||||
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.card2)", Item.rich(order: "2048", title: "Kestrel plans"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Ship it"))
|
||||
}
|
||||
|
||||
private func cardFolder(_ fixture: WriterFixture, lane: String, card: String) -> URL {
|
||||
fixture.url("\(lane)/\(card)")
|
||||
}
|
||||
|
||||
/// Writes one comment's `index.md` under a card, verbatim.
|
||||
private func writeComment(
|
||||
_ fixture: WriterFixture,
|
||||
inCard cardPath: String,
|
||||
named name: String,
|
||||
body: String
|
||||
) throws {
|
||||
try fixture.item("\(cardPath)/comments/\(name)", commentText(body: body))
|
||||
}
|
||||
|
||||
private let kestrelBody = "The kestrel hovers over the release notes.\n"
|
||||
|
||||
// MARK: - The sweep
|
||||
|
||||
@Suite("Comment search ▸ the sweep")
|
||||
struct CommentSweepTests {
|
||||
|
||||
@Test("It reads every posted comment's body")
|
||||
func readsPostedBodies() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: kestrelBody)
|
||||
try writeComment(fixture, inCard: card, named: CommentIdent.two, body: "A second remark.\n")
|
||||
|
||||
let bodies = CommentThread.searchableBodies(inCard: fixture.url(card))
|
||||
#expect(bodies.count == 2)
|
||||
#expect(bodies.contains(kestrelBody))
|
||||
#expect(bodies.contains("A second remark.\n"))
|
||||
}
|
||||
|
||||
@Test("A card with no comments/ contributes nothing, and does not fail")
|
||||
func noThreadIsNoBodies() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)).isEmpty)
|
||||
}
|
||||
|
||||
@Test("The draft is excluded — it is not in the thread, so it is not in the search")
|
||||
func draftIsExcluded() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try writeComment(fixture, inCard: card, named: ".draft", body: "A kestrel I have not posted.\n")
|
||||
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: "Posted.\n")
|
||||
|
||||
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)) == ["Posted.\n"])
|
||||
}
|
||||
|
||||
@Test("comments/.trash/ is excluded — a deleted comment is undo's, never a search result")
|
||||
func commentTrashIsExcluded() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: card,
|
||||
named: ".trash/\(CommentIdent.two)",
|
||||
body: "A kestrel I deleted.\n"
|
||||
)
|
||||
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: "Posted.\n")
|
||||
|
||||
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)) == ["Posted.\n"])
|
||||
}
|
||||
|
||||
@Test("Malformed comments are tolerated: a stray folder, one with no index.md, one that will not parse")
|
||||
func defectsAreTolerated() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: kestrelBody)
|
||||
// Not identity-shaped — a hand-made folder inside the thread.
|
||||
try writeComment(fixture, inCard: card, named: "notes", body: "A kestrel in a stray.\n")
|
||||
// Identity-shaped with no `index.md` — the two-step-create shape.
|
||||
try FileManager.default.createDirectory(
|
||||
at: fixture.url("\(card)/comments/\(CommentIdent.three)"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
// Frontmatter that opens a flow sequence and never closes it.
|
||||
try fixture.item(
|
||||
"\(card)/comments/\(CommentIdent.upper)",
|
||||
"---\nschema: 1\norder: [1024\n---\nA kestrel in a broken file.\n"
|
||||
)
|
||||
|
||||
// The one readable comment, and nothing else — none of the three costs the sweep an error.
|
||||
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)) == [kestrelBody])
|
||||
}
|
||||
|
||||
@Test("The sweep indexes bodies per card, and skips cards with nothing to say")
|
||||
func sweepBuildsTheIndex() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: "\(Ident.lane1)/\(Ident.card1)",
|
||||
named: CommentIdent.one,
|
||||
body: kestrelBody
|
||||
)
|
||||
|
||||
let index = CommentSearchIndex.sweep([
|
||||
CommentSearchTarget(id: ItemID(rawValue: Ident.card1), folder: cardFolder(fixture, lane: Ident.lane1, card: Ident.card1)),
|
||||
CommentSearchTarget(id: ItemID(rawValue: Ident.card2), folder: cardFolder(fixture, lane: Ident.lane1, card: Ident.card2))
|
||||
])
|
||||
|
||||
#expect(index[ItemID(rawValue: Ident.card1)] == [kestrelBody])
|
||||
#expect(index[ItemID(rawValue: Ident.card2)] == nil)
|
||||
}
|
||||
|
||||
@Test("Targets cover the board and the trash — a trashed card carries its comments/")
|
||||
func targetsIncludeTheTrash() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Gone"))
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
let targets = CommentSearchIndex.targets(in: model)
|
||||
let ids = Set(targets.map(\.id))
|
||||
#expect(ids.contains(ItemID(rawValue: Ident.card1)))
|
||||
#expect(ids.contains(ItemID(rawValue: Ident.card3)))
|
||||
#expect(ids.contains(ItemID(rawValue: Ident.card4)))
|
||||
// The folder is where a thread would be read from — the trash card's own, not its old lane's.
|
||||
let trashed = try #require(targets.first { $0.id == ItemID(rawValue: Ident.card4) })
|
||||
#expect(trashed.folder.path.hasSuffix(".trash/\(Ident.card4)"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Matching
|
||||
|
||||
@Suite("Comment search ▸ matching")
|
||||
struct CommentIndexMatchingTests {
|
||||
|
||||
@Test("The query folds exactly as the board's does — case and diacritics")
|
||||
func foldingIsTheBoardsOwn() {
|
||||
let card = ItemID(rawValue: Ident.card1)
|
||||
let index = [card: ["A Résumé of the kestrel.\n"]]
|
||||
|
||||
#expect(CommentSearchIndex.matchingCards(in: index, query: "KESTREL") == [card])
|
||||
#expect(CommentSearchIndex.matchingCards(in: index, query: "resume") == [card])
|
||||
#expect(CommentSearchIndex.matchingCards(in: index, query: "falcon").isEmpty)
|
||||
}
|
||||
|
||||
@Test("An empty query matches nothing — the index is only consulted while a search is running")
|
||||
func emptyQueryMatchesNothing() {
|
||||
let card = ItemID(rawValue: Ident.card1)
|
||||
#expect(CommentSearchIndex.matchingCards(in: [card: ["anything"]], query: "").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A query cannot match across two comments — the bodies are a list, never a joined string")
|
||||
func noMatchAcrossComments() {
|
||||
let card = ItemID(rawValue: Ident.card1)
|
||||
let index = [card: ["ends with kes", "trel begins here"]]
|
||||
#expect(CommentSearchIndex.matchingCards(in: index, query: "kestrel").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A card matches when its comments do, even though its own fields miss")
|
||||
func filterRoutesCommentMatches() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
let card = try #require(model.lanes.first?.cards.first { $0.id == ItemID(rawValue: Ident.card1) })
|
||||
|
||||
// Fields only: "Fix login" is not a kestrel.
|
||||
#expect(!SearchFilter(query: "kestrel").matches(card))
|
||||
// With the index' answer, the same card is on the board.
|
||||
#expect(SearchFilter(query: "kestrel", commentMatches: [card.id]).matches(card))
|
||||
}
|
||||
|
||||
@Test("The comment clause never widens an inactive filter, and never reaches the trash lane row")
|
||||
func theClauseIsNarrow() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
let card = try #require(model.lanes.first?.cards.first)
|
||||
|
||||
// An empty query shows everything, index or no index.
|
||||
#expect(SearchFilter(query: "", commentMatches: []).matches(card))
|
||||
// A trashed lane row is matched by title alone — it is an opaque unit with no thread of its
|
||||
// own, so an id in the comment set cannot make one appear.
|
||||
let lane = TrashedLane(
|
||||
id: ItemID(rawValue: Ident.lane3),
|
||||
schema: 1,
|
||||
title: .valid("Retired"),
|
||||
order: 1024,
|
||||
heldCards: 2,
|
||||
document: FrontmatterDocument(body: "")
|
||||
)
|
||||
#expect(!SearchFilter(query: "kestrel", commentMatches: [lane.id]).matches(lane))
|
||||
}
|
||||
|
||||
@Test("Visible ids carry the comment match through to every surface that filters")
|
||||
func visibleIDsCarryTheMatch() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
let plain = SearchFilter(query: "kestrel").visibleIDs(in: model, container: .board)
|
||||
#expect(!plain.contains(ItemID(rawValue: Ident.card1)))
|
||||
#expect(plain.contains(ItemID(rawValue: Ident.card2)))
|
||||
|
||||
let withComments = SearchFilter(query: "kestrel", commentMatches: [ItemID(rawValue: Ident.card1)])
|
||||
.visibleIDs(in: model, container: .board)
|
||||
#expect(withComments.contains(ItemID(rawValue: Ident.card1)))
|
||||
#expect(withComments.contains(ItemID(rawValue: Ident.card2)))
|
||||
#expect(!withComments.contains(ItemID(rawValue: Ident.card3)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The index' lifecycle
|
||||
|
||||
@MainActor
|
||||
@Suite("Comment search ▸ the transient index")
|
||||
struct CommentSearchIndexTests {
|
||||
|
||||
private func makeIndexed(_ fixture: WriterFixture) throws -> [CommentSearchTarget] {
|
||||
try makeSearchBoard(fixture)
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: "\(Ident.lane1)/\(Ident.card1)",
|
||||
named: CommentIdent.one,
|
||||
body: kestrelBody
|
||||
)
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
return CommentSearchIndex.targets(in: model)
|
||||
}
|
||||
|
||||
@Test("The first keystroke answers field-only, and refines when the sweep lands")
|
||||
func refinesWhenReady() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let targets = try makeIndexed(fixture)
|
||||
let index = CommentSearchIndex()
|
||||
var refinements = 0
|
||||
index.onRefine = { refinements += 1 }
|
||||
|
||||
index.update(query: "kestrel", generation: 1, targets: targets)
|
||||
// Nothing yet: the sweep is I/O and does not run on the keystroke's actor. This is the
|
||||
// documented moment where the board shows field-only matches.
|
||||
#expect(index.matchingCards.isEmpty)
|
||||
#expect(refinements == 0)
|
||||
|
||||
await index.awaitSweep()
|
||||
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
|
||||
#expect(refinements == 1)
|
||||
}
|
||||
|
||||
@Test("A landed refinement that changes nothing does not re-run the selection constraint")
|
||||
func silentWhenNothingChanged() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let targets = try makeIndexed(fixture)
|
||||
let index = CommentSearchIndex()
|
||||
var refinements = 0
|
||||
index.onRefine = { refinements += 1 }
|
||||
|
||||
index.update(query: "no such bird", generation: 1, targets: targets)
|
||||
await index.awaitSweep()
|
||||
#expect(index.matchingCards.isEmpty)
|
||||
#expect(refinements == 0)
|
||||
}
|
||||
|
||||
@Test("A later keystroke re-filters in memory — no second sweep, and the answer is immediate")
|
||||
func laterKeystrokesDoNotSweep() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let targets = try makeIndexed(fixture)
|
||||
let index = CommentSearchIndex()
|
||||
|
||||
index.update(query: "kes", generation: 1, targets: targets)
|
||||
await index.awaitSweep()
|
||||
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
|
||||
|
||||
// Synchronously correct, with no await: the bodies are in hand, so a keystroke costs a
|
||||
// predicate pass and no I/O at all.
|
||||
index.update(query: "kestrel", generation: 1, targets: targets)
|
||||
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
|
||||
index.update(query: "kestrels", generation: 1, targets: targets)
|
||||
#expect(index.matchingCards.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A new snapshot generation re-sweeps, and the fresh answer replaces the old one")
|
||||
func generationChangeResweeps() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let targets = try makeIndexed(fixture)
|
||||
let index = CommentSearchIndex()
|
||||
|
||||
index.update(query: "kestrel", generation: 1, targets: targets)
|
||||
await index.awaitSweep()
|
||||
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
|
||||
|
||||
// The comment is edited on disk out of the match — the case an agent produces.
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: "\(Ident.lane1)/\(Ident.card1)",
|
||||
named: CommentIdent.one,
|
||||
body: "The falcon hovers instead.\n"
|
||||
)
|
||||
// The stale answer stands until the sweep lands — results refine, they never blank.
|
||||
index.update(query: "kestrel", generation: 2, targets: targets)
|
||||
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
|
||||
|
||||
await index.awaitSweep()
|
||||
#expect(index.matchingCards.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Clearing the query discards the index outright")
|
||||
func clearingDiscards() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let targets = try makeIndexed(fixture)
|
||||
let index = CommentSearchIndex()
|
||||
|
||||
index.update(query: "kestrel", generation: 1, targets: targets)
|
||||
await index.awaitSweep()
|
||||
#expect(!index.matchingCards.isEmpty)
|
||||
|
||||
index.update(query: "", generation: 1, targets: targets)
|
||||
#expect(index.matchingCards.isEmpty)
|
||||
|
||||
// And the *bodies* went with it: re-activating at the same generation has to sweep again,
|
||||
// which is observable as the field-only moment coming back.
|
||||
index.update(query: "kestrel", generation: 1, targets: targets)
|
||||
#expect(index.matchingCards.isEmpty)
|
||||
await index.awaitSweep()
|
||||
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Through the store
|
||||
|
||||
@MainActor
|
||||
@Suite("Comment search ▸ the store's funnel")
|
||||
struct CommentSearchStoreTests {
|
||||
|
||||
@Test("Typing a query sweeps, and the card matching only through its comments joins the board")
|
||||
func theQueryReachesComments() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: "\(Ident.lane1)/\(Ident.card1)",
|
||||
named: CommentIdent.one,
|
||||
body: kestrelBody
|
||||
)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.searchQuery = "kestrel"
|
||||
#expect(store.commentIndex.matchingCards.isEmpty)
|
||||
|
||||
await store.commentIndex.awaitSweep()
|
||||
#expect(store.searchFilter.matches(try #require(card(Ident.card1, in: store))))
|
||||
#expect(store.searchFilter.matches(try #require(card(Ident.card2, in: store))))
|
||||
#expect(!store.searchFilter.matches(try #require(card(Ident.card3, in: store))))
|
||||
}
|
||||
|
||||
@Test("Clearing the search discards the index — the board goes back to holding nothing")
|
||||
func clearingTheSearchDiscards() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: "\(Ident.lane1)/\(Ident.card1)",
|
||||
named: CommentIdent.one,
|
||||
body: kestrelBody
|
||||
)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.searchQuery = "kestrel"
|
||||
await store.commentIndex.awaitSweep()
|
||||
#expect(!store.commentIndex.matchingCards.isEmpty)
|
||||
|
||||
store.clearSearch()
|
||||
#expect(store.commentIndex.matchingCards.isEmpty)
|
||||
// Everything is visible again, which is what an inactive filter means.
|
||||
#expect(store.searchFilter.matches(try #require(card(Ident.card3, in: store))))
|
||||
}
|
||||
|
||||
@Test("A selection kept alive by a comment match is not evicted by the filter")
|
||||
func commentMatchesKeepTheSelection() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeSearchBoard(fixture)
|
||||
try writeComment(
|
||||
fixture,
|
||||
inCard: "\(Ident.lane1)/\(Ident.card1)",
|
||||
named: CommentIdent.one,
|
||||
body: kestrelBody
|
||||
)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([ItemID(rawValue: Ident.card1)], in: .board)
|
||||
|
||||
// The keystroke lands before the sweep does, so the card is hidden and leaves the selection —
|
||||
// 04's "hidden cards leave the selection", applied honestly to what is known at the time.
|
||||
store.searchQuery = "kestrel"
|
||||
#expect(store.selection.ids.isEmpty)
|
||||
|
||||
// A card selected *after* the sweep survives the next constraint, which is the half the
|
||||
// refine seam exists to keep true.
|
||||
await store.commentIndex.awaitSweep()
|
||||
store.select([ItemID(rawValue: Ident.card1)], in: .board)
|
||||
store.searchQuery = "kestrel h"
|
||||
store.searchQuery = "kestrel"
|
||||
#expect(store.selection.ids == [ItemID(rawValue: Ident.card1)])
|
||||
}
|
||||
|
||||
private func card(_ id: String, in store: BoardStore) -> Card? {
|
||||
store.snapshot.lanes.flatMap(\.cards).first { $0.id == ItemID(rawValue: id) }
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,44 @@ struct UITestFixtureBoardTests {
|
||||
#expect(richCard.attachments == [UITestLaunch.attachmentName])
|
||||
}
|
||||
|
||||
/// The rich card's **comment thread** — what the comments-pane audit is an audit *of*
|
||||
/// (10-accessibility.md ▸ Comments).
|
||||
///
|
||||
/// The three shapes are the point: an ordinary comment, an unattributed one, and an edited one.
|
||||
/// Each drives a different branch of the author line a flattened comment element wears
|
||||
/// (`CommentAuthorLine`), and a fixture that quietly lost one of them would leave the audit
|
||||
/// passing over a thread of three identical rows.
|
||||
@Test("The rich card carries a thread the pane's audit can read")
|
||||
func fixtureSeedsAThread() throws {
|
||||
UITestLaunch.prepareScratchDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
||||
|
||||
let root = try UITestLaunch.materializeFixtureBoard()
|
||||
let model = try BoardLoader.load(boardRoot: root).model
|
||||
let lane = model.lanes[UITestLaunch.richCardIndex.lane]
|
||||
let card = try #require(lane.cards.first)
|
||||
let cardFolder = ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
|
||||
|
||||
let thread = CommentThread.load(inCard: cardFolder, path: "\(lane.id.rawValue)/\(card.id.rawValue)")
|
||||
#expect(thread.comments.count == UITestLaunch.commentBodies.count)
|
||||
#expect(thread.strays.isEmpty)
|
||||
// Posting restamps `created`, so the thread's chronological order is the order they were made.
|
||||
#expect(thread.comments.map(\.body) != [])
|
||||
|
||||
let unattributed = try #require(
|
||||
thread.comments.first { $0.body.contains("no `author` key") }
|
||||
)
|
||||
#expect(unattributed.author.value == nil)
|
||||
#expect(!unattributed.isEdited)
|
||||
|
||||
let edited = try #require(thread.comments.first { $0.body == UITestLaunch.editedCommentBody })
|
||||
#expect(edited.isEdited, "the edited marker is `modified` differing from `created`, and no extra field")
|
||||
#expect(edited.author.value != nil)
|
||||
|
||||
// The board walk never loads any of it — the pane reads its own thread (01 ▸ Enhanced schema).
|
||||
#expect(!card.body.contains("specimen thread"))
|
||||
}
|
||||
|
||||
/// Everything the fixture launch writes stays inside the app's own container — the sandbox
|
||||
/// constraint that decided the whole design (a path handed over on the command line would not be
|
||||
/// readable), stated as a test so a future "just use `/tmp`" cannot land quietly.
|
||||
|
||||
Reference in New Issue
Block a user