Files
lanework/KanbanTests/SearchFilterTests.swift
T
rzen 5c0c0e5619 Realign search, clipboard, and lane-hover code with the second batch
Creation now clears the search by mechanism, not gesture: one seam
(noteUserCreation) states 04's rule once, called from the placeholder
funnel, paste — cards and lanes, after the staleness guard so a stale
paste clears nothing — and Finder file-drop creation; the attach path
deliberately doesn't clear, and cross-board arrivals and New Lane stay
outside the seam (a transfer isn't creation; a lane can't be born
invisible). An open inline rename now survives the filter hiding its
card: the model already kept the editor, but the field renders in the
card's slot, so renderedCards keeps the renaming card's slot exactly as
long as the editor is open — the query stands throughout, and commit or
Escape lets the predicate apply in the same pass. Verified conformant
and newly pinned: query-emptied lanes keep their slot with a 0 badge,
pasteboard staleness (takeover before paste and mid-staging both no-op),
out-transition reachability and the strip pre-divide hold by
construction with comments citing their rulings.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:45:12 -04:00

664 lines
27 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) -> 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])
}
}
// 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, on: .live)
#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], liveness: .live)
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 liveness'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.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
await reload(store)
#expect(store.transient.renameEditor == nil)
}
}