Files
lanework/KanbanTests/CardLabelsTests.swift
T
rzen 0609104b2d The card menu learns to tag — a labels submenu of twelve checkmarks, and Copy Link moves in beside its mirror
Right-clicking a card now offers Labels: up to twelve rows ranked by how many cards on the
board carry each one, tie-broken by what this user reached for last, each a checkmark that
says whether *this* card already has it. More… opens the whole inventory beside the card, in
lookup order, with a field that mints a name the board has never used.

The rows deliberately do not widen to the selection the way Copy, Cut and Send to Trash do. A
checkmark is a claim about one card, and three cards where two carry `bug` have no honest
checked state — the rows that widen are the ones that say what they will do rather than what
is already true. It is also what keeps the menu cheap: a context menu's contents are rebuilt
on every ordinary pass of every card face, so reading the selection there would resubscribe
the whole board. The board-sized half of the ranking is cached on the store and gated on
equality; what runs per face is bounded by how many distinct labels exist, not by how many
cards do.

Copy Link leaves the first group for a new Copy Special submenu, mirroring Paste Special —
the placement the owner's latest layout asks for, and the one the row's own note has been
waiting on since it shipped as a same-day deviation. It keeps its VoiceOver action even so: it
is an action that changed doors, not a door.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 12:06:01 -04:00

762 lines
34 KiB
Swift

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 More… dialog's session
@MainActor
@Suite("Labels ▸ the More… dialog's lifecycle")
struct LabelEditorSessionTests {
@Test("Begin aims it at one card; a second Begin re-aims rather than stacking")
func beginAndDiscard() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.transient.labelEditor == nil)
store.transient.beginLabelEditor(forCard: clipboardCard1)
#expect(store.transient.labelEditor?.cardID == clipboardCard1)
store.transient.beginLabelEditor(forCard: clipboardCard2)
#expect(store.transient.labelEditor?.cardID == clipboardCard2, "one dialog, re-aimed")
store.transient.discardLabelEditor()
#expect(store.transient.labelEditor == nil)
}
@Test("The reload rule takes it down when its card is no longer a live board card")
func theReloadRule() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
#expect(LabelEditorSession(cardID: clipboardCard1).resolved(against: snapshot) != nil)
// A trashed card resolves nowhere on the board side, which is exactly right: the write the
// dialog offers (`BoardStore.setLabels`) refuses a trashed card.
#expect(LabelEditorSession(cardID: clipboardCard3).resolved(against: snapshot) == nil)
#expect(LabelEditorSession(cardID: ItemID(rawValue: Ident.indexless))
.resolved(against: snapshot) == nil)
}
@Test("A card that leaves the board while the dialog is open dismisses it")
func aVanishedCardDismisses() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginLabelEditor(forCard: clipboardCard1)
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.transient.labelEditor == nil)
// And the style session's own lifecycle is untouched by the new neighbour.
#expect(store.transient.styleEditor == nil)
}
@Test("The two sessions are independent — neither `begin` clears the other")
func theTwoSessionsAreIndependent() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([clipboardCard1]))
store.transient.beginLabelEditor(forCard: clipboardCard1)
#expect(store.transient.styleEditor != nil)
#expect(store.transient.labelEditor != nil)
store.transient.discardLabelEditor()
#expect(store.transient.styleEditor != nil, "discarding one leaves the other")
}
}
// MARK: - The menu's funnel
@MainActor
@Suite("Labels ▸ the command funnel")
struct LabelCommandTests {
private func settle(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
@Test("Toggle adds what is missing and removes what is there")
func toggling() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
#expect(LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents))
await settle(store)
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c", "spike"])
#expect(LabelCommand.toggle("SPIKE", onCard: clipboardCard1, in: store, recents: recents))
await settle(store)
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"], "case-insensitive, like everywhere")
}
@Test("An apply records the MRU; a removal deliberately does not")
func recording() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)
#expect(recents.labels == ["spike"])
await settle(store)
LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)
#expect(recents.labels == ["spike"], "taking a label off is not reaching for it")
// And an add onto a card that already carries it still records — the user reached for it.
await settle(store)
LabelCommand.add("a", onCard: clipboardCard1, in: store, recents: recents)
#expect(recents.labels == ["a", "spike"])
}
@Test("A typed name lands in the board's own spelling")
func spellingResolution() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
// The board already spells it `a`; typing `A` must not mint a second variant.
#expect(LabelCommand.resolved("A", in: store) == "a")
#expect(LabelCommand.resolved("brand-new", in: store) == "brand-new", "a new name is the typist's")
#expect(LabelCommand.resolved(" ", in: store) == nil)
LabelCommand.add("A", onCard: clipboardCard2, in: store, recents: recents)
await settle(store)
#expect(store.labelIndex.names.allSatisfy { $0 != "A" }, "no second spelling entered the universe")
#expect(recents.labels == ["a"])
}
}
// 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))
}
}