|
|
|
@@ -0,0 +1,629 @@
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|