import Foundation import Testing @testable import Kanban /// **The `labels` key, activated** (2026-08-09, Pipeline cards a4462d28 and 28c79ffe) — the schema's /// reading and writing, the rules of a list of names, the board-wide used-labels universe, the menu's /// ranking, and the store write that lands it. /// /// The key's own story is `FrontmatterKeys.labels`: it was a reserved tracker key beside `assignees`, /// `due` and `remote` until the owner claimed it for first-party use, which is the design event these /// tests exist to pin. Everything here is a pure seam except the last suite, which writes real bytes. // MARK: - The name rules @Suite("Labels ▸ one name") struct CardLabelNameTests { @Test("A name is trimmed, and nothing is not a name") func normalization() { #expect(CardLabels.normalized("bug") == "bug") #expect(CardLabels.normalized(" bug ") == "bug") #expect(CardLabels.normalized("needs review") == "needs review", "interior spaces are the author's") #expect(CardLabels.normalized("") == nil) #expect(CardLabels.normalized(" ") == nil) #expect(CardLabels.normalized("\n\t") == nil) } @Test("Identity is case-insensitive; display is not") func caseFolding() { #expect(CardLabels.canonical("Bug") == CardLabels.canonical("bug")) #expect(CardLabels.contains("BUG", in: ["bug"])) #expect(CardLabels.contains("bug", in: ["Bug"])) #expect(!CardLabels.contains("bugs", in: ["bug"])) #expect(!CardLabels.contains(" ", in: ["bug"])) } } // MARK: - The list rules @Suite("Labels ▸ one list") struct CardLabelListTests { @Test("Duplicates collapse case-insensitively, and the first spelling holds its place") func deduplication() { #expect(CardLabels.deduplicated(["Bug", "ui", "bug"]) == ["Bug", "ui"]) #expect(CardLabels.deduplicated([" bug ", "BUG"]) == ["bug"]) #expect(CardLabels.deduplicated(["a", "", " ", "b"]) == ["a", "b"]) #expect(CardLabels.deduplicated([]) == []) } @Test("Adding appends — never sorts, never respells an existing label") func adding() { #expect(CardLabels.adding("ui", to: ["bug"]) == ["bug", "ui"]) // Alphabetically `a` belongs first; the user put it last, so it goes last. #expect(CardLabels.adding("a", to: ["z"]) == ["z", "a"]) #expect(CardLabels.adding("Bug", to: ["bug"]) == ["bug"], "the existing spelling wins") #expect(CardLabels.adding(" ", to: ["bug"]) == ["bug"]) } @Test("Removing takes every case-variant, and toggling is the pair") func removingAndToggling() { #expect(CardLabels.removing("BUG", from: ["bug", "ui"]) == ["ui"]) #expect(CardLabels.removing("nope", from: ["bug"]) == ["bug"]) #expect(CardLabels.toggling("ui", in: ["bug"]) == ["bug", "ui"]) #expect(CardLabels.toggling("UI", in: ["bug", "ui"]) == ["bug"]) #expect(CardLabels.toggling("ui", in: CardLabels.toggling("ui", in: ["bug"])) == ["bug"], "twice is a no-op") } } // MARK: - Reading @Suite("Labels ▸ the schema's reading") struct CardLabelReadingTests { private func document(_ frontmatter: String) throws -> FrontmatterDocument { try FrontmatterDocument.parse("---\nschema: 1\n\(frontmatter)---\nBody.\n") } @Test("Both list spellings read the same") func listForms() throws { #expect(try document("labels: [bug, ui]\n").labels == .valid(["bug", "ui"])) #expect(try document("labels: [\"bug\", \"needs review\"]\n").labels == .valid(["bug", "needs review"])) #expect(try document("labels:\n - bug\n - ui\n").labels == .valid(["bug", "ui"])) #expect(try document("labels: []\n").labels == .valid([])) } @Test("A bare scalar coerces to one label") func scalarCoercion() throws { #expect(try document("labels: bug\n").labels == .valid(["bug"])) #expect(try document("labels: \"needs review\"\n").labels == .valid(["needs review"])) #expect(try document("labels: \" bug \"\n").labels == .valid(["bug"]), "trimmed like any other name") } @Test("Absent, and an explicit null, are both no labels") func absence() throws { #expect(try document("").labels.isMissing) #expect(try document("labels:\n").labels.isMissing) #expect(try document("labels: null\n").labels.isMissing) } @Test("A mapping — and a non-string scalar — have no list reading at all") func malformedShapes() throws { #expect(try document("labels: {a: 1}\n").labels.isMalformed) // Deliberately not coerced to `["3"]`: see `CardLabels.name(of:)`. A card is not labelled // with a number because somebody wrote one. #expect(try document("labels: 3\n").labels.isMalformed) #expect(try document("labels: true\n").labels.isMalformed) } @Test("Entries with no name reading are skipped, not fatal") func nonStringEntries() throws { #expect(try document("labels: [bug, 3, {a: 1}, ui]\n").labels == .valid(["bug", "ui"])) // A list of nothing but unreadable entries is a list, and a list with no names in it is // `.valid([])` — the author wrote the right shape. #expect(try document("labels: [{a: 1}]\n").labels == .valid([])) } @Test("The reading deduplicates case-insensitively, first spelling winning") func readingDeduplicates() throws { #expect(try document("labels: [Bug, ui, bug]\n").labels == .valid(["Bug", "ui"])) } @Test("A shape with no reading leaves the coerce tier's trace") func coerceTrace() throws { let coerced = try document("labels: {a: 1}\n").coercedFields #expect(coerced.contains { $0.key == FrontmatterKeys.labels }) #expect(try document("labels: [bug]\n").coercedFields.isEmpty) } @Test("`labels` is schema-owned, so Details never draws a second copy of it") func schemaOwnership() throws { let parsed = try document("labels: [bug]\nproject: lanework\n") #expect(FrontmatterKeys.schemaOwned.contains(FrontmatterKeys.labels)) #expect(parsed.unknownFields.map(\.key) == ["project"]) #expect(CardDetails.rows(of: parsed).map(\.key) == ["project"]) // The three still-reserved tracker keys are untouched by the reversal. for reserved in [FrontmatterKeys.remote, "assignees", "due"] { #expect(!FrontmatterKeys.schemaOwned.contains(reserved), "\(reserved) is still reserved") } } @Test("The snapshot carries the reading on the card itself") func theCardCarriesIt() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\n---\n") try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\nkind: card\norder: 1024\nlabels: [bug, ui]\n---\n") let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes.first?.cards.first?.labels == .valid(["bug", "ui"])) } } // MARK: - Writing @Suite("Labels ▸ the canonical write") struct CardLabelWriteTests { private func rewritten(_ frontmatter: String, to names: [String]) throws -> String { var document = try FrontmatterDocument.parse("---\nschema: 1\n\(frontmatter)---\nBody.\n") document.setLabels(names) return document.serialized() } @Test("Names land as a quoted flow list, in the order given") func canonicalForm() throws { #expect(try rewritten("", to: ["bug", "ui"]).contains("labels: [\"bug\", \"ui\"]\n")) // No sort: the caller's order is the user's order. #expect(try rewritten("", to: ["z", "a"]).contains("labels: [\"z\", \"a\"]\n")) // Quoting is what makes a name with a comma safe inside a flow collection. #expect(try rewritten("", to: ["a, b"]).contains("labels: [\"a, b\"]\n")) } @Test("A rewrite replaces the value in place and touches nothing else") func byteSurgical() throws { let after = try rewritten( "title: Fix login\nlabels: [old]\nproject: lanework # agent overlay\n", to: ["new"] ) #expect(after == """ --- schema: 1 title: Fix login labels: ["new"] project: lanework # agent overlay --- Body. """) } @Test("The last label removes the key — the remove-at-default family") func emptyRemovesTheKey() throws { let after = try rewritten("labels: [bug]\nproject: lanework\n", to: []) #expect(!after.contains("labels")) #expect(after.contains("project: lanework\n")) // And a document that never had the key does not grow one. #expect(try rewritten("", to: []) == "---\nschema: 1\n---\nBody.\n") } @Test("Entries with no name reading ride through, at the tail") func preservedEntries() throws { #expect(try rewritten("labels: [bug, 3, {a: 1}]\n", to: ["ui"]) .contains("labels: [\"ui\", 3, {a: 1}]\n")) // And they keep the key alive even when every name is gone. #expect(try rewritten("labels: [bug, 3]\n", to: []).contains("labels: [3]\n")) } @Test("A malformed value is replaced outright — the malformed-value-cleared posture") func malformedIsReplaced() throws { #expect(try rewritten("labels: {a: 1}\n", to: ["bug"]).contains("labels: [\"bug\"]\n")) #expect(try rewritten("labels: bug\n", to: ["bug", "ui"]).contains("labels: [\"bug\", \"ui\"]\n")) } @Test("The write applies the list rules, so no caller can land a duplicate") func writeDeduplicates() throws { #expect(try rewritten("", to: ["Bug", "bug", " ", "ui"]).contains("labels: [\"Bug\", \"ui\"]\n")) } @Test("Write, read, write — the canonical form is a fixed point") func roundTrip() throws { var document = try FrontmatterDocument.parse("---\nschema: 1\nlabels:\n - bug\n - ui\n---\nBody.\n") let read = try #require(document.labels.value) document.setLabels(read) let once = document.serialized() var again = try FrontmatterDocument.parse(once) again.setLabels(try #require(again.labels.value)) #expect(again.serialized() == once) #expect(again.labels == .valid(["bug", "ui"])) } } // MARK: - The universe @Suite("Labels ▸ the used-labels universe") struct LabelIndexTests { private func board(_ labelsPerCard: [[String]], trash: [[String]] = []) throws -> BoardModel { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, "---\nschema: 1\nkind: lane\norder: 1024\n---\n") func text(_ labels: [String], order: Int) -> String { let list = labels.map { "\"\($0)\"" }.joined(separator: ", ") let key = labels.isEmpty ? "" : "labels: [\(list)]\n" return "---\nschema: 1\nkind: card\norder: \(order)\n\(key)---\n" } for (offset, labels) in labelsPerCard.enumerated() { try fixture.item("\(Ident.lane1)/\(Self.identifier(offset))", text(labels, order: (offset + 1) * 1024)) } for (offset, labels) in trash.enumerated() { try fixture.item(".trash/\(Self.identifier(100 + offset))", text(labels, order: (offset + 1) * 1024)) } return try BoardLoader.load(boardRoot: fixture.root).model } private static func identifier(_ n: Int) -> String { let hex = String(format: "%012x", n) return "aaaaaaaa-aaaa-4aaa-8aaa-\(hex)" } @Test("A board with no labels has an empty universe") func empty() throws { #expect(LabelIndex.derive(from: try board([[], []])) == LabelIndex.empty) #expect(LabelIndex.empty.isEmpty) #expect(LabelIndex.empty.alphabetical.isEmpty) } @Test("Every live card contributes, and frequency is a card count") func counting() throws { let index = LabelIndex.derive(from: try board([["bug", "ui"], ["bug"], ["bug", "ops"]])) #expect(index.names == ["bug", "ops", "ui"], "frequency first, then alphabetical") #expect(index.tallies.map(\.count) == [3, 1, 1]) } @Test("The trash counts — deleting the last card carrying a label does not evict it") func theTrashCounts() throws { let index = LabelIndex.derive(from: try board([["bug"]], trash: [["spike"], ["bug"]])) #expect(index.names == ["bug", "spike"]) #expect(index.tallies.first?.count == 2) } @Test("Two cards spelling one label two ways are one label, in the first spelling seen") func spellingIsBoardOrder() throws { let index = LabelIndex.derive(from: try board([["Bug"], ["bug"], ["bug"]])) #expect(index.names == ["Bug"]) #expect(index.tallies.first?.count == 3) #expect(index.contains("BUG")) #expect(index.canonicalSpelling(of: "bug") == "Bug") #expect(index.canonicalSpelling(of: "nope") == nil) } @Test("The derivation is a total order, so two runs agree") func deterministic() throws { let model = try board([["b", "a"], ["a", "b"], ["c"]]) #expect(LabelIndex.derive(from: model) == LabelIndex.derive(from: model)) #expect(LabelIndex.derive(from: model).names == ["a", "b", "c"]) } @Test("The dialog's listing is alphabetical, not frequency-ordered") func alphabetical() throws { let index = LabelIndex.derive(from: try board([["zebra", "apple"], ["zebra"]])) #expect(index.names == ["zebra", "apple"], "frequency for the menu") #expect(index.alphabetical == ["apple", "zebra"], "lookup order for the dialog") } } // MARK: - The ranking @Suite("Labels ▸ the menu's twelve") struct LabelRankingTests { private func index(_ pairs: [(String, Int)]) -> LabelIndex { LabelIndex(tallies: pairs.map { LabelTally(name: $0.0, count: $0.1) } .sorted { $0.count != $1.count ? $0.count > $1.count : $0.name < $1.name }) } @Test("Frequency leads") func frequencyLeads() { let ranked = LabelRanking.ranked(index([("rare", 1), ("common", 9)]), recents: ["rare"]) #expect(ranked == ["common", "rare"], "recency does not outrank frequency") } @Test("Recency breaks a frequency tie, most recent first") func recencyBreaksTies() { let tied = index([("a", 2), ("b", 2), ("c", 2)]) #expect(LabelRanking.ranked(tied, recents: ["c", "b"]) == ["c", "b", "a"]) #expect(LabelRanking.ranked(tied, recents: []) == ["a", "b", "c"], "alphabetical with no MRU") } @Test("A label the MRU has never seen sorts behind every label it has") func unseenSortsLast() { let tied = index([("known", 1), ("unknown", 1)]) #expect(LabelRanking.ranked(tied, recents: ["known"]) == ["known", "unknown"]) } @Test("MRU entries naming labels this board does not use are inert") func foreignRecentsAreIgnored() { let tied = index([("a", 1), ("b", 1)]) #expect(LabelRanking.ranked(tied, recents: ["from-another-board", "b"]) == ["b", "a"]) } @Test("Recency matching is case-insensitive, like every other label comparison") func recencyFolds() { let tied = index([("Bug", 1), ("ui", 1)]) #expect(LabelRanking.ranked(tied, recents: ["BUG"]) == ["Bug", "ui"]) } @Test("The limit is twelve, and it truncates the ranking rather than the universe") func theLimit() { let many = index((0 ..< 20).map { (String(format: "label%02d", $0), 20 - $0) }) #expect(LabelRanking.menuLimit == 12) #expect(LabelRanking.ranked(many, recents: []).count == 12) #expect(LabelRanking.ranked(many, recents: []).first == "label00", "the most used") #expect(LabelRanking.ranked(many, recents: [], limit: 0).isEmpty) #expect(LabelRanking.ranked(LabelIndex.empty, recents: ["a"]).isEmpty) } /// The consequence the ranking's own doc comment names out loud, pinned so it cannot change /// silently: a brand-new label does **not** jump the queue on a board with a full menu. @Test("A freshly invented label waits its turn behind twelve busier ones") func freshLabelsDoNotJumpTheQueue() { var pairs = (0 ..< 12).map { (String(format: "busy%02d", $0), 5) } pairs.append(("brand-new", 1)) let ranked = LabelRanking.ranked(index(pairs), recents: ["brand-new"]) #expect(!ranked.contains("brand-new")) #expect(ranked.count == 12) } } // MARK: - The MRU @Suite("Labels ▸ the recents list") struct LabelRecentsTests { /// `@MainActor` for the same reason `StyleRecents`' own rule is reached that way: the function is /// pure, but it lives on a `@MainActor` class, so the isolation rides along. @MainActor @Test("Move to front, deduped case-insensitively, capped") func theListRule() { #expect(LabelRecents.updated([], with: "bug") == ["bug"]) #expect(LabelRecents.updated(["a", "b"], with: "b") == ["b", "a"]) #expect(LabelRecents.updated(["Bug", "a"], with: "bug") == ["bug", "a"], "the new spelling lands") #expect(LabelRecents.updated(["a"], with: " ") == ["a"]) #expect(LabelRecents.updated((0 ..< 30).map(String.init), with: "x", cap: 3) == ["x", "0", "1"]) } @MainActor @Test("Recording persists, and an unchanged list writes nothing observable") func persistence() { let suite = "LabelRecentsTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let recents = LabelRecents(defaults: defaults) #expect(recents.labels.isEmpty) recents.record("bug") recents.record("ui") #expect(recents.labels == ["ui", "bug"]) recents.record("ui") #expect(recents.labels == ["ui", "bug"], "already at the front") #expect(LabelRecents(defaults: defaults).labels == ["ui", "bug"], "read back from the domain") } @MainActor @Test("A hand-broken preference is an empty list, never a crash") func lenientRead() { let suite = "LabelRecentsTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } defaults.set(42, forKey: AppPreferences.labelRecentsKey) #expect(LabelRecents(defaults: defaults).labels.isEmpty) } } // MARK: - The write vocabulary @Suite("Labels ▸ the write operation") struct LabelWriteOperationTests { @Test("`.relabel` is its own word, enriched with the card's title, and it stamps") func theOperation() { #expect(WriteOperation.relabel(title: nil).withTitle("Fix login") == .relabel(title: "Fix login")) #expect(!WriteOperation.relabel(title: "Fix login").rewritesOrderOnly, "a labels change is content") #expect(WriteOperation.relabel(title: "Fix login").description == "relabel 'Fix login'") #expect(WriteOperation.relabel(title: nil).description == "relabel") } @Test("The undo step's field compares the whole list") func theExpectedField() throws { let document = try FrontmatterDocument.parse("---\nschema: 1\nlabels: [\"bug\", \"ui\"]\n---\n") #expect(HistoryStaleness.matches(.labels(["bug", "ui"]), in: document)) #expect(!HistoryStaleness.matches(.labels(["bug"]), in: document), "a foreign retag stales it") #expect(!HistoryStaleness.matches(.labels(nil), in: document)) let bare = try FrontmatterDocument.parse("---\nschema: 1\n---\n") #expect(HistoryStaleness.matches(.labels(nil), in: bare)) #expect(!HistoryStaleness.matches(.labels([]), in: bare), "an absent key and an empty list differ") let broken = try FrontmatterDocument.parse("---\nschema: 1\nlabels: {a: 1}\n---\n") #expect(!HistoryStaleness.matches(.labels(nil), in: broken), "a malformed value matches nothing") } @Test("The menu row and the change journal say the same word") func thePhrase() { #expect(HistoryPhrase.name(.relabel, kind: .card) == "Relabel Card") #expect(HistoryPhrase.Verb.relabel.rawValue == "Relabel") } } // MARK: - The store write @MainActor @Suite("Labels ▸ the store write") struct LabelStoreWriteTests { private func labels(of card: String, lane: String, in fixture: WriterFixture) throws -> FieldValue<[String]> { try FrontmatterDocument.parse(fixture.indexText("\(lane)/\(card)")).labels } /// The reload between two writes is not ceremony: the no-op guard reads the **snapshot**, which is /// one reload behind every write the app makes (the one-way flow) — `SetAsHeroTests`' own note. private func settle(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() } @Test("Setting a list writes the canonical form; emptying it takes the key") func setThenEmpty() async throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.setLabels(["bug", "ui"], onCard: clipboardCard1)) #expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["bug", "ui"])) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("labels: [\"bug\", \"ui\"]")) await settle(store) #expect(store.setLabels([], onCard: clipboardCard1)) #expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing) #expect(store.banners.oneShots.isEmpty) } @Test("Writing the list a card already has is a no-op — no write, no step") func redundantWritesAreFree() async throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // `Item.rich` ships `labels: [a, b, c]`, so the card already reads exactly this. #expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"]) let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") #expect(!store.setLabels(["a", "b", "c"], onCard: clipboardCard1)) #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) // And the normalization runs before the comparison, so a differently-spelled no-op is one too. #expect(!store.setLabels(["a", "b", "c", "A"], onCard: clipboardCard1)) #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) } @Test("A write stamps `modified` and clears foreign attribution, like any content write") func itStamps() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.setLabels(["bug"], onCard: clipboardCard1)) let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) #expect(document.modifiedBy == .missing, "`Item.rich` wrote `modified-by: claude`") #expect(document.title == .valid("First"), "and nothing else moved") } @Test("The guards are the hero's: the board container, and cards only") func theGuards() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(!store.setLabels(["bug"], onCard: clipboardCard3), "in the trash") #expect(!store.setLabels(["bug"], onCard: clipboardLane1), "a lane has no labels") #expect(!store.setLabels(["bug"], onCard: ItemID(rawValue: Ident.indexless)), "names nothing") } @Test("The read seam sees both containers, even where the write refuses") func readingSpansContainers() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"]) #expect(store.labels(ofCard: clipboardCard3).isEmpty, "the trash fixture carries no labels key") #expect(store.labels(ofCard: ItemID(rawValue: Ident.indexless)).isEmpty) } @Test("The cached universe is derived at open and re-derived on a landing") func theCachedUniverse() async throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Every `Item.rich` card ships `labels: [a, b, c]`; the trash resident ships none. #expect(store.labelIndex.names == ["a", "b", "c"]) #expect(store.labelIndex.tallies.first?.count == 3, "the three `Item.rich` cards; the trash resident has none") #expect(store.setLabels(["a", "b", "c", "spike"], onCard: clipboardCard1)) await settle(store) #expect(store.labelIndex.contains("spike")) #expect(store.labelIndex.names.last == "spike", "one card carries it, so it ranks last") } @Test("⌘Z puts the previous list back, and ⇧⌘Z the new one") func undoAndRedo() async throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history #expect(store.setLabels(["bug"], onCard: clipboardCard1)) #expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["bug"])) #expect(history.undoActionName == "Relabel Card") history.undo() #expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["a", "b", "c"])) history.redo() #expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["bug"])) } @Test("Undoing the write that removed the last label restores it; undoing a first write removes the key") func undoAcrossTheKeysBoundary() async throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history // `card4` is `Item.rich` too, so start by clearing it, then undo across the key's removal. #expect(store.setLabels([], onCard: clipboardCard4)) #expect(try labels(of: Ident.card4, lane: Ident.lane2, in: fixture).isMissing) history.undo() #expect(try labels(of: Ident.card4, lane: Ident.lane2, in: fixture) == .valid(["a", "b", "c"])) } } // MARK: - The sidebar's picker @Suite("Labels ▸ the sidebar's add field") struct CardLabelsPickerTests { private let universe = ["bug", "ui", "backend", "needs review"] @Test("An empty query offers the board's own vocabulary") func emptyQuery() { #expect(CardLabelsPicker.suggestions(for: "", universe: universe, existing: []) == universe) #expect(CardLabelsPicker.suggestions(for: " ", universe: universe, existing: []) == universe) } @Test("Matching is a case-insensitive substring, board search's own rule") func matching() { #expect(CardLabelsPicker.suggestions(for: "U", universe: universe, existing: []) == ["bug", "ui"]) #expect(CardLabelsPicker.suggestions(for: "review", universe: universe, existing: []) == ["needs review"]) #expect(CardLabelsPicker.suggestions(for: "zzz", universe: universe, existing: []).isEmpty) } @Test("A label the card already carries is never offered") func existingIsExcluded() { #expect(CardLabelsPicker.suggestions(for: "", universe: universe, existing: ["BUG"]) == ["ui", "backend", "needs review"]) } @Test("The list is capped") func theCap() { let many = (0 ..< 30).map { "label\($0)" } #expect(CardLabelsPicker.suggestions(for: "label", universe: many, existing: []).count == CardLabelsPicker.suggestionLimit) #expect(CardLabelsPicker.suggestions(for: "", universe: many, existing: [], limit: 2).count == 2) } @Test("The create hint fires only for a name the board has never spelled") func theCreateHint() { #expect(CardLabelsPicker.createsNewLabel("spike", universe: universe)) #expect(!CardLabelsPicker.createsNewLabel("BUG", universe: universe), "that is an apply, not a create") #expect(!CardLabelsPicker.createsNewLabel(" ", universe: universe)) #expect(!CardLabelsPicker.createsNewLabel("", universe: universe)) } }