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) } } }