Implement live search filtering

The board's live title+body filter per 04-interactions.md § Search:

- SearchFilter — a pure value folding the query once (case- and
  diacritic-insensitive substring, locale-stable); title OR body matches,
  attachment filenames never searched; only the literal empty string is
  inactive.
- One universe: the filter threads through SelectionGrammar's order lists
  as a defaulted parameter, so ranges, Select All, arrow navigation, the
  marquee, drop zones, count badges, and the shown trash all read the same
  filtered set by construction; lanes are deliberately never filtered out
  (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the
  selection through the existing constrain primitive, run on every query
  change and as the last line of the reload resolve; the delete successor
  is filtered so ⌫ never selects a hidden neighbour.
- The field: an NSSearchField-backed toolbar item (the toolbar's sole
  default item); Edit ▸ Find ⌘F focuses it through a focused-value
  presentation; stock field-editor dispatch — Return swallowed, Tab is the
  keep-filter path to the board, board commands stay enabled except the
  caret-chord pair, now one shared caretChordsYield expression.
- Escape is staged: clear the non-empty query (focus stays), hand an empty
  field back to the board, clear an active search from board focus —
  before Escape's clear-selection meaning.
- Creating a card clears the search (the placeholder funnel); a rename
  deliberately gets no carve-out; filter reflow rides the content spring
  keyed narrowly on the query.

903 unit tests (24 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 22:49:15 -04:00
parent 7eee0934ee
commit cf87b72092
15 changed files with 1359 additions and 84 deletions
+551
View File
@@ -0,0 +1,551 @@
import Foundation
import Testing
@testable import Kanban
/// 04-interactions.md § Search, from the predicate outward.
///
/// The design gives the filter one sentence of behaviour and one sentence of *reach*, and the two
/// need different kinds of test. The predicate is pure, so the first suite below reads like a truth
/// table folding, title-or-body, the empty query, and the scope line that keeps attachment
/// filenames out. The reach is the interesting half: "the filter is the single source of truth for
/// what's on the board ranges, arrow nav, and lane count badges all read it", which is a claim
/// about the *order lists* and about the selection, and the second and third suites pin it there.
///
/// The boards are **real loads off real temp trees**, as in `SelectionGrammarTests`: every rule here
/// reads a body, an `isDeleted`, an attachment listing or `TrashModel`'s sort, and a hand-built
/// `BoardModel` would let all four drift from what the loader actually produces. `WriterFixture`,
/// `Ident` and `Item` live in `WriterTestSupport.swift`.
// MARK: - Fixtures
private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
}
/// A card or lane whose title and body are **independently controlled** which `Item.rich` cannot
/// be, since its body quotes its title, and a title-or-body test needs the two to disagree.
private func item(order: String, title: String?, body: String) -> String {
var lines = ["---", "schema: 1"]
if let title { lines.append("title: \(title)") }
lines.append("order: \(order)")
lines.append("---")
return lines.joined(separator: "\n") + "\n" + body + "\n"
}
private func tombstoned(
order: String,
title: String,
body: String,
deleted: String = "2026-03-05T10:00:00Z"
) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: \(deleted)
---
\(body)
"""
}
/// Three lanes and five cards, built so every clause of the predicate has a card that isolates it:
///
/// | card | title | body | what it proves |
/// |---|---|---|---|
/// | `card1` | Fix login | (no "login") | the title half, and case folding |
/// | `card2` | Résumé polish | (no match) | diacritic folding |
/// | `card3` | *untitled* | "the login service." | the body half, with no title at all |
/// | `card4` | Unrelated | "Nothing here." | the miss plus an attachment named `budget.csv` |
/// | `card5` | Archive | "Old material." | a second miss, in a third lane |
///
/// A query of `login` therefore leaves exactly `card1` and `card3` standing, in two different lanes
/// which is what makes a flatten-order range across the gap worth asserting.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
item(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
item(order: "2048", title: "Résumé polish", body: "Tighten the wording.")
)
try fixture.item(Ident.lane2, item(order: "2048", title: "Doing", body: "Work in progress."))
try fixture.item(
"\(Ident.lane2)/\(Ident.card3)",
item(order: "1024", title: nil, body: "Deploy the login service.")
)
try fixture.item(
"\(Ident.lane2)/\(Ident.card4)",
item(order: "2048", title: "Unrelated", body: "Nothing here.")
)
// A real attachment, so the scope rule is tested against a card the loader really did list files
// for rather than against an empty array that would pass by accident.
try fixture.file("\(Ident.lane2)/\(Ident.card4)/attachments/budget.csv", Data("a,b\n".utf8))
try fixture.item(Ident.lane3, item(order: "3072", title: "Done", body: "Shipped."))
try fixture.item(
"\(Ident.lane3)/\(More.card5)",
item(order: "1024", title: "Archive", body: "Old material.")
)
return fixture
}
/// A trash holding both row kinds, one of each matching `login` the shape "participates in the
/// filter like any lane" needs, since a lane entry is filtered by its *own* title and body.
///
/// `TrashModel`'s sort is newest first, so the row order is `[card1, laneX, card2]`.
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z")
)
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z")
)
try fixture.item(
More.laneX,
tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z")
)
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let card5 = ItemID(rawValue: More.card5)
private let laneX = ItemID(rawValue: More.laneX)
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func card(_ id: ItemID, in model: BoardModel) throws -> Card {
try #require(model.lanes.flatMap(\.cards).first { $0.id == id })
}
/// One reload, start to settled the only way transient state gets re-resolved.
@MainActor
private func reload(_ store: BoardStore, origin: WatchOrigin = .foreign) async {
store.handleWatcherEvent(.treeChanged(origin))
await store.awaitQuiescence()
}
// MARK: - The predicate
@MainActor
@Suite("SearchFilter — the predicate")
struct SearchFilterPredicateTests {
@Test("An empty query is no filter at all: every card matches and nothing is hidden")
func emptyQueryShowsEverything() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let filter = SearchFilter(query: "")
#expect(!filter.isActive)
#expect(SearchFilter.inactive == filter)
for card in model.lanes.flatMap(\.cards) {
#expect(filter.matches(card))
}
}
@Test("Whitespace is a substring like any other — only the empty string turns the filter off")
func whitespaceIsALegitimateQuery() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// "Trim the query" is a rule the design does not state, and a live filter shows the result of
// every keystroke so a trailing space mid-word narrows rather than resetting to everything.
let filter = SearchFilter(query: "fix ")
#expect(filter.isActive)
#expect(filter.matches(try card(card1, in: model)))
#expect(!filter.matches(try card(card4, in: model)))
}
@Test("Matching is case-insensitive, in either direction")
func caseInsensitive() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = try card(card1, in: try load(fixture))
#expect(SearchFilter(query: "FIX LOGIN").matches(first))
#expect(SearchFilter(query: "fix login").matches(first))
#expect(SearchFilter(query: "AUTH FLOW").matches(first))
}
@Test("Matching is diacritic-insensitive, in either direction")
func diacriticInsensitive() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let polish = try card(card2, in: try load(fixture))
// Both directions, because folding one side only would pass the first and fail the second.
#expect(SearchFilter(query: "resume").matches(polish))
#expect(SearchFilter(query: "RÉSUMÉ").matches(polish))
#expect(SearchFilter(query: "Résumé").matches(polish))
}
@Test("A card matches on its title OR its body; missing both is what hides it")
func titleOrBody() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let filter = SearchFilter(query: "login")
// The title half: card1's body says nothing about logins.
let byTitle = try card(card1, in: model)
#expect(byTitle.title.value == "Fix login")
#expect(!byTitle.body.contains("login"))
#expect(filter.matches(byTitle))
// The body half, on a card with no title at all "Untitled" is a rendering, never a value,
// so nothing about the placeholder can be searched.
let byBody = try card(card3, in: model)
#expect(byBody.title.value == nil)
#expect(filter.matches(byBody))
#expect(!SearchFilter(query: "untitled").matches(byBody))
// The miss: "cards whose title *and* body both miss the query animate out".
#expect(!filter.matches(try card(card4, in: model)))
}
@Test("Attachment filenames are not searched — scope is title + body only")
func attachmentNamesAreOutOfScope() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let unrelated = try card(card4, in: try load(fixture))
// The listing is real, so the exclusion below is a decision rather than an empty array.
#expect(unrelated.attachments == ["budget.csv"])
#expect(!SearchFilter(query: "budget").matches(unrelated))
#expect(!SearchFilter(query: "csv").matches(unrelated))
}
@Test("A lane matches by its own title and body — the trash's rows, not the board's lanes")
func lanesMatchByTheirOwnText() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let todo = try #require(model.lanes.first { $0.id == lane1 })
#expect(SearchFilter(query: "todo").matches(todo))
#expect(SearchFilter(query: "inbox").matches(todo))
// A lane is not matched through its cards: `card1` says "login", the lane does not.
#expect(!SearchFilter(query: "login").matches(todo))
}
@Test("The visible universe keeps every live lane and only the matching cards")
func visibleUniverse() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live)
// Lanes are never hidden by a card query a lane the filter empties is still a lane on the
// board, so a lane selection survives a query that empties its body.
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
#expect(visible.intersection([card1, card2, card3, card4, card5]) == [card1, card3])
}
}
// MARK: - The order lists
@MainActor
@Suite("SearchFilter — the order lists")
struct SearchFilterOrderTests {
@Test("The live card order narrows to the survivors, in flatten order")
func liveCardsNarrow() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order(
of: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@Test("The lane order is untouched by a query — a card filter hides no lane")
func laneOrderIsUnfiltered() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// `lane3`'s only card misses the query, and the lane is still in the order: the width
// division is layout, and a `0` badge is the honest report.
#expect(SelectionGrammar.order(
of: .lane,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == [lane1, lane2, lane3])
}
@Test("A ⇧-range under a query spans only the survivors between its endpoints")
func rangesWalkTheFilteredBoard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// Unfiltered, card1 card3 sweeps card2 up with it.
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model)
== [card1, card2, card3])
// Filtered, the span between the same two endpoints is the two that are on screen.
#expect(SelectionGrammar.range(
from: card1,
to: card3,
kind: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@Test("A hidden endpoint is a missing one: the range degrades exactly as it does for a deleted card")
func aHiddenEndpointHasNoRange() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.range(
from: card1,
to: card2,
kind: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == nil)
}
@Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text")
func trashEntriesNarrow() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2])
#expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX])
let filter = SearchFilter(query: "login")
#expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1])
// The lane row matches on its *own* title, not on the card buried inside it.
#expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX])
#expect(SelectionGrammar.trashEntries(
of: .lane,
in: model,
filter: SearchFilter(query: "polish")
).isEmpty)
}
@Test("The delete successor is drawn from what the lane is showing")
func successorSkipsHiddenSiblings() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// Unfiltered, deleting the untitled card lands on its lane's next card.
#expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4)
// Under `login`, card4 is hidden and there is nothing else visible in that lane, so the
// honest answer is nothing rather than a card the query animated out.
#expect(SelectionGrammar.successor(
afterDeleting: [card3],
in: model,
filter: SearchFilter(query: "login")
) == nil)
}
}
// MARK: - The store seam
@MainActor
@Suite("SearchFilter — the board's universe")
struct SearchFilterStoreTests {
@Test("Select All under a query selects the visible cards only")
func selectAllIsFilterRespecting() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.selectAll()
#expect(store.selection.ids == [card1, card2, card3, card4, card5])
store.searchQuery = "login"
store.selectAll()
#expect(store.selection.ids == [card1, card3])
}
@Test("Select All on the trash side reads the filter too")
func selectAllInTheTrashIsFilterRespecting() async throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([card2], liveness: .trashed)
store.selectAll()
#expect(store.selection.ids == [card1, card2])
store.select([card1], liveness: .trashed)
store.searchQuery = "login"
store.selectAll()
#expect(store.selection.ids == [card1])
}
@Test("Hidden cards leave the selection the moment the query narrows — anchor and head with them")
func aQueryChangeConstrainsTheSelection() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
store.searchQuery = "login"
#expect(store.selection.ids == [card1])
// The anchor is still visible, so a -click still ranges from it; the head was hidden, so
// the arrows re-derive from the set's last member on the next press.
#expect(store.transient.selectionAnchor == card1)
#expect(store.transient.selectionHead == nil)
}
@Test("A lane selection survives a query that empties the lane")
func laneSelectionsSurviveTheFilter() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], liveness: .live)
store.searchQuery = "login"
#expect(store.selection.ids == [lane3])
}
@Test("Clearing the query widens the board and disturbs nothing")
func clearingTheSearchRestoresTheBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.clearSearch()
#expect(store.searchQuery.isEmpty)
#expect(store.selection.ids == [card1])
#expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5)
}
@Test("A reload landing under an active query re-applies the filter to the selection")
func aReloadUnderAQueryConstrains() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1, card3], liveness: .live, anchor: card1, head: card3)
// An agent edits the title out of the match. The card is still there this is not a vanish,
// so only the *filter's* universe can eject it.
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
item(order: "1024", title: "Fix auth", body: "The auth flow breaks on retry.")
)
await reload(store)
#expect(store.snapshot.lanes.flatMap(\.cards).contains { $0.id == card1 })
#expect(store.selection.ids == [card3])
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == card3)
}
@Test("Creating a card clears the search — a brand-new card must not be born invisible")
func creationClearsTheSearch() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
// The one funnel every creation entry point goes through N, Return on a lane, the header
// button, a double-click on empty space.
store.transient.beginPlaceholder(inLane: lane3)
#expect(store.searchQuery.isEmpty)
#expect(store.transient.newCardPlaceholder?.laneID == lane3)
}
@Test("Rename gets no carve-out: the query stands, and a card renamed out of it leaves the selection")
func renameDoesNotClearTheSearch() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
#expect(store.searchQuery == "login")
store.transient.updateRenameDraft("Fix auth")
store.commitRename()
await reload(store, origin: .appMediated)
// "A title that stops matching animates the card out and drops it from the selection,
// exactly as an agent's edit would" and the filter stays a pure predicate, so nothing here
// is arranged by the rename path itself.
#expect(store.searchQuery == "login")
#expect(try card(card1, in: store.snapshot).title.value == "Fix auth")
#expect(store.selection.isEmpty)
}
@Test("A rename that keeps the card matching keeps it selected")
func aStillMatchingRenameKeepsItsCard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login again")
store.commitRename()
await reload(store, origin: .appMediated)
#expect(store.selection.ids == [card1])
}
@Test("Deleting under a query walks the filtered lane")
func deleteSelectsAVisibleSuccessor() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// `Todo` shows both its cards under this query, so the successor is the visible neighbour.
store.searchQuery = "the"
store.select([card1], liveness: .live)
store.delete([card1])
#expect(store.selection.ids == [card2])
}
}