The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
466 lines
20 KiB
Swift
466 lines
20 KiB
Swift
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"),
|
|
modified: .missing,
|
|
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) }
|
|
}
|
|
}
|