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
695 lines
30 KiB
Swift
695 lines
30 KiB
Swift
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, modified: String? = nil) -> String {
|
|
var lines = ["---", "schema: 1"]
|
|
if let title { lines.append("title: \(title)") }
|
|
lines.append("order: \(order)")
|
|
// The trash sorts by `modified` descending (01-storage-format.md § Deletion, re-ruled
|
|
// 2026-07-31), so a trash fixture states its column order here rather than in `order`.
|
|
if let modified { lines.append("modified: \(modified)") }
|
|
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**, interleaved by rank — the shape the column's own filter rule
|
|
/// needs (03-board-ui.md § Trash, lanes rejoined 2026-07-29: "The row matches the search filter by
|
|
/// lane title only").
|
|
///
|
|
/// `laneX`'s body says `login` and its title does not, which is what makes the title-only rule
|
|
/// checkable rather than merely stated: a query of `login` leaves `card1` alone.
|
|
///
|
|
/// The ranks put the lane row between the two cards, so the merged 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."))
|
|
// The column reads [card1, laneX, card2] — by their stamps, both kinds interleaved.
|
|
try fixture.item(
|
|
".trash/\(Ident.card1)",
|
|
item(order: "1024", title: "Fix login", body: "Auth.", modified: "2026-05-05T09:00:00Z"))
|
|
try fixture.item(
|
|
".trash/\(More.laneX)",
|
|
"---\nschema: 1\ntitle: Archive\norder: 1536\nmodified: 2026-05-03T09:00:00Z\nkind: lane\n---\nOld login notes.\n"
|
|
)
|
|
try fixture.item(
|
|
".trash/\(Ident.card2)",
|
|
item(order: "2048", title: "Polish", body: "Wording.", modified: "2026-05-01T09: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("There is no lane predicate: the filter is a card predicate, end to end")
|
|
func lanesAreNeverFiltered() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
|
|
// 04 § Search filters *cards*. Under the tombstone model the trash held lane *entries* that
|
|
// had to be matched like rows, which is why `matches(_: Lane)` existed; lanes are never
|
|
// trashed now, so the overload went with them and every lane is always visible.
|
|
let visible = SearchFilter(query: "nothing-matches-this").visibleIDs(in: model, container: .board)
|
|
#expect(visible == Set(model.lanes.map(\.id)))
|
|
}
|
|
|
|
@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, container: .board)
|
|
// 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.boardCards(in: model) == [card1, card2, card3, card4, card5])
|
|
#expect(SelectionGrammar.boardCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
|
|
#expect(SelectionGrammar.order(of: .card, in: .board, snapshot: 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, in: .board, snapshot: 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, in: .board, snapshot: 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, in: .board, snapshot: 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, in: .board, snapshot: model,
|
|
filter: SearchFilter(query: "login")
|
|
) == nil)
|
|
}
|
|
|
|
@Test("Trash cards participate in the filter exactly like any other card")
|
|
func trashCardsNarrow() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
|
|
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
|
|
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
|
|
}
|
|
|
|
/// "The row matches the search filter by lane title only" (03-board-ui.md § Trash) — the opaque
|
|
/// unit's own rule, and the one place a lane *is* filtered: `laneX`'s body carries the query and
|
|
/// the row still leaves the column, because its body is not on screen.
|
|
@Test("A trashed lane row filters by title alone")
|
|
func trashLaneRowsFilterByTitle() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
|
|
#expect(SelectionGrammar.trashLanes(in: model) == [laneX])
|
|
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX])
|
|
// The body says "login". The row does not.
|
|
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty)
|
|
// And the ranging grammar's list is the whole column, both kinds — kind-blind trash selection
|
|
// (04-interactions.md ▸ The trash, re-ruled 2026-07-31).
|
|
#expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2])
|
|
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [card1, laneX, card2])
|
|
}
|
|
|
|
@Test("The trash's visible universe is its matching rows, both kinds")
|
|
func trashUniverseNarrows() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
|
|
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
|
|
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2, laneX])
|
|
}
|
|
|
|
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
|
|
/// participate in the filter exactly like any other card — the point of the pivot"
|
|
/// (03-board-ui.md § Trash). `LaneView.rendered`'s trash-side twin, pinned the same way: the
|
|
/// column, its count badge and its marquee registration all read this list, so one answer keeps
|
|
/// them in step.
|
|
@Test("The trash column renders exactly what navigation and Select All walk")
|
|
func trashColumnRendersTheFilteredCards() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
|
|
// Both kinds, interleaved by `modified` descending (03-board-ui.md § Trash).
|
|
#expect(TrashLaneView.rendered(model, filter: .inactive).map(\.id) == [card1, laneX, card2])
|
|
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1])
|
|
// The column and the arrow grammar cannot disagree about what is on screen.
|
|
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id)
|
|
== SelectionGrammar.trashRows(in: model, filter: SearchFilter(query: "login")))
|
|
// A query nothing matches empties the column without emptying the container — which is why
|
|
// Empty Trash's validation reads `.trash/` and not this list.
|
|
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "zzzz")).isEmpty)
|
|
#expect(!model.trash.isEmpty)
|
|
#expect(!model.trashedLanes.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], snapshot: 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],
|
|
snapshot: 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], in: .trash)
|
|
store.selectAll()
|
|
// Every visible *row*, both kinds — kind-blind since 2026-07-31.
|
|
#expect(store.selection.ids == [card1, laneX, card2])
|
|
|
|
store.select([card1], in: .trash)
|
|
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], in: .board, 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], in: .board)
|
|
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], in: .board)
|
|
store.clearSearch()
|
|
|
|
#expect(store.searchQuery.isEmpty)
|
|
#expect(store.selection.ids == [card1])
|
|
#expect(SelectionGrammar.boardCards(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], in: .board, 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], in: .board)
|
|
|
|
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], in: .board)
|
|
|
|
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], in: .board)
|
|
store.delete([card1])
|
|
#expect(store.selection.ids == [card2])
|
|
}
|
|
}
|
|
|
|
// MARK: - What the filter does not reach
|
|
|
|
/// The three surfaces 04-interactions.md § Search exempts from the predicate, each settled and each
|
|
/// with a different mechanism behind it:
|
|
///
|
|
/// - **lanes**, which "are never filtered out" — nothing consults the filter to decide whether to
|
|
/// build a lane, so an all-misses lane keeps its slot and its badge simply reads `0`;
|
|
/// - **an open inline rename**, which "survives the filter hiding its card" — the editor outlives a
|
|
/// reload that stops its card matching, and its card keeps the masonry slot the field is drawn in;
|
|
/// - the **selection's** own carve-out is the opposite claim and lives in the suite above.
|
|
@MainActor
|
|
@Suite("SearchFilter — the surfaces it does not reach")
|
|
struct SearchFilterExemptionTests {
|
|
|
|
@Test("A lane the query empties keeps its slot: the board's structure is not a search result")
|
|
func lanesAreNeverFilteredOut() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
let filter = SearchFilter(query: "login")
|
|
|
|
// Every live lane is in the visible universe, `Done` — which holds only a miss — included.
|
|
let visible = filter.visibleIDs(in: model, container: .board)
|
|
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
|
|
#expect(!visible.contains(card5))
|
|
|
|
// And what the emptied lane *renders* is nothing at all, which is the `0` badge: the count
|
|
// reads this same list (`LaneView.countBadge`).
|
|
let done = try #require(model.lanes.first { $0.id == lane3 })
|
|
#expect(LaneView.rendered(done.cards, hiddenByDrag: [], filter: filter, renaming: nil).isEmpty)
|
|
// The lane with one match keeps exactly that one.
|
|
let todo = try #require(model.lanes.first { $0.id == lane1 })
|
|
#expect(LaneView.rendered(todo.cards, hiddenByDrag: [], filter: filter, renaming: nil)
|
|
.map(\.id) == [card1])
|
|
}
|
|
|
|
@Test("An open inline rename keeps its card's slot, and loses it the moment the editor closes")
|
|
func theRenamingCardKeepsItsSlot() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
let filter = SearchFilter(query: "login")
|
|
let doing = try #require(model.lanes.first { $0.id == lane2 })
|
|
|
|
// `card4` misses the query; while it is being renamed it renders anyway, because the field is
|
|
// drawn in its slot and unmounting the slot would discard the keystrokes.
|
|
#expect(LaneView.rendered(doing.cards, hiddenByDrag: [], filter: filter, renaming: card4)
|
|
.map(\.id) == [card3, card4])
|
|
#expect(LaneView.rendered(doing.cards, hiddenByDrag: [], filter: filter, renaming: nil)
|
|
.map(\.id) == [card3])
|
|
}
|
|
|
|
@Test("A dragged card stays lifted even while it is the one being renamed")
|
|
func theLiftOutranksTheExemption() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let model = try load(fixture)
|
|
let doing = try #require(model.lanes.first { $0.id == lane2 })
|
|
|
|
// The exemption is about the *filter*, and only the filter: a card lifted out of the resting
|
|
// layout is not being hidden, it is being carried.
|
|
#expect(LaneView.rendered(
|
|
doing.cards, hiddenByDrag: [card3], filter: .inactive, renaming: card3
|
|
).map(\.id) == [card4])
|
|
}
|
|
|
|
@Test("A foreign edit that stops the renaming card matching leaves the editor open and focused")
|
|
func theEditorSurvivesAFilteringReload() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.searchQuery = "login"
|
|
store.select([card1], in: .board)
|
|
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
|
store.transient.updateRenameDraft("Fix login thoroughly")
|
|
|
|
// An agent edits the title out of the match while the user is typing. Not a vanish — the card
|
|
// is still there — so the vanish-discard rule stays out of 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.transient.renameEditor?.targetID == card1)
|
|
#expect(store.transient.renameEditor?.draftTitle == "Fix login thoroughly")
|
|
// The card left the *selection* — that rule is untouched — and the commit still writes it.
|
|
#expect(store.selection.isEmpty)
|
|
store.commitRename()
|
|
await reload(store, origin: .appMediated)
|
|
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly")
|
|
}
|
|
|
|
@Test("A vanish still discards the editor — the carve-out is the filter's, not the container's")
|
|
func aVanishStillDiscardsTheEditor() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.searchQuery = "login"
|
|
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
|
await reload(store)
|
|
|
|
#expect(store.transient.renameEditor == nil)
|
|
}
|
|
}
|