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(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth.")) try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording.")) 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]) // And there is no lane list in the trash at all — "Cards only. Lanes are never trashed". #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty) } @Test("The trash's visible universe is its matching cards") 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]) } /// 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) #expect(TrashLaneView.rendered(model.trash, filter: .inactive).map(\.id) == [card1, card2]) #expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == [card1]) // The column and the arrow grammar cannot disagree about what is on screen. #expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == SelectionGrammar.trashCards(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.trash, filter: SearchFilter(query: "zzzz")).isEmpty) #expect(!model.trash.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() #expect(store.selection.ids == [card1, 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) } }