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:
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user