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