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)
    }
}
