Files
lanework/KanbanTests/ColorComboTests.swift
T
rzen ece33bbf78 The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover
The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.

The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 09:44:30 -04:00

198 lines
10 KiB
Swift

import AppKit
import Testing
@testable import Kanban
/// `ColorComboView`'s pure model (`ColorCombo.swift`): item-list composition, selection matching,
/// hex normalization and display-name casing — every rule stated where a test can hold it without
/// an `NSView`, matching `PaletteTests.swift`'s own split between the vocabulary and the view.
///
/// One outer suite, nested by concern — swift-testing discovers nested types as sub-suites, which
/// is what lets `-only-testing:KanbanTests/ColorComboTests` run the whole file while each concern
/// still reads as its own group, `PaletteTests.swift`'s top-level-structs style scoped one level in.
struct ColorComboTests {
// MARK: - Display names
struct DisplayName {
@Test func kebabCaseBecomesTitleCaseWithHyphensAsSpaces() {
#expect(ColorComboModel.displayName("light-cayenne") == "Light Cayenne")
#expect(ColorComboModel.displayName("smokey-rich-eggplant") == "Smokey Rich Eggplant")
#expect(ColorComboModel.displayName("obsidian") == "Obsidian")
#expect(ColorComboModel.displayName("deep-sky-blue") == "Deep Sky Blue")
}
}
// MARK: - Hex normalization
struct HexNormalization {
@Test func sixDigitHexUppercasesUnchanged() {
#expect(ColorComboModel.normalizedHex("#b6071e") == "#B6071E")
#expect(ColorComboModel.normalizedHex("#B6071E") == "#B6071E")
}
/// Alpha below full opacity is meaningful and stays — only a fully-opaque suffix collapses.
@Test func eightDigitHexWithPartialAlphaStaysEightDigits() {
#expect(ColorComboModel.normalizedHex("#b6071e80") == "#B6071E80")
}
/// `#RRGGBBFF` — fully opaque, spelled with an explicit alpha byte — collapses to the
/// six-digit form, so it compares equal to a bare `#RRGGBB` written for the same colour.
@Test func fullyOpaqueEightDigitHexCollapsesToSixDigits() {
#expect(ColorComboModel.normalizedHex("#b6071eFF") == "#B6071E")
#expect(ColorComboModel.normalizedHex("#B6071EFF") == ColorComboModel.normalizedHex("#B6071E"))
}
@Test func malformedOrUnprefixedValuesNormalizeToNil() {
for value in ["", "#", "#12", "#12345", "#1234567", "#GGGGGG", "B6071E", "light-cayenne"] {
#expect(ColorComboModel.normalizedHex(value) == nil, "'\(value)' should not normalize")
}
}
}
// MARK: - Selection matching
struct Matching {
@Test func nilValueMatchesNone() {
#expect(ColorComboModel.match(role: .background, value: nil) == .none)
#expect(ColorComboModel.match(role: .foreground, value: nil) == .none)
}
@Test func aNameInTheRolesOwnPaletteMatchesThatRowExactly() {
#expect(ColorComboModel.match(role: .background, value: "light-cayenne") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .foreground, value: "fern") == .palette("fern"))
}
/// Names are matched exactly, like `Palette.nsColor(for:)` — a near-miss is not "the same
/// row", it is an off-palette value with its own dynamic row.
@Test func aNearMissNameIsNotAPaletteMatch() {
let match = ColorComboModel.match(role: .background, value: "Light-Cayenne")
guard case let .current(_, title) = match else {
Issue.record("expected .current, got \(match)")
return
}
#expect(title == "Light-Cayenne")
}
/// A hex that normalizes to one of the role's own palette hexes selects the **name**, not
/// the hex — case-insensitively, and with a fully-opaque `#RRGGBBFF` collapsing exactly like
/// a bare `#RRGGBB` would.
@Test func aHexEqualToAPaletteColorsHexMatchesItsNamedRow() {
#expect(ColorComboModel.match(role: .background, value: "#B6071E") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#b6071e") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#B6071EFF") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#b6071eff") == .palette("light-cayenne"))
}
/// Partial alpha keeps a value off the palette rows even when its RGB matches one exactly —
/// the stored colour is genuinely translucent, which no palette entry is.
@Test func aTranslucentHexNeverMatchesAnOpaquePaletteColor() {
let match = ColorComboModel.match(role: .background, value: "#B6071E80")
guard case let .current(swatchValue, _) = match else {
Issue.record("expected .current, got \(match)")
return
}
#expect(swatchValue == "#B6071E80")
}
/// A name from the *other* picker's table — `carnation` is foreground-only — is not one of
/// `.background`'s own table, so it falls to the dynamic row, titled with its own display name
/// since the other table does know it.
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
let match = ColorComboModel.match(role: .background, value: "carnation")
#expect(match == .current(swatchValue: "carnation", title: "Carnation"))
}
/// A custom hex nowhere in either table: the dynamic row states it verbatim, uppercased.
@Test func aCustomHexFallsToTheDynamicRowUppercased() {
let match = ColorComboModel.match(role: .background, value: "#123456")
#expect(match == .current(swatchValue: "#123456", title: "#123456"))
let lowercase = ColorComboModel.match(role: .background, value: "#abcdef")
#expect(lowercase == .current(swatchValue: "#abcdef", title: "#ABCDEF"))
}
/// Unresolvable garbage — neither a name either table knows nor a parseable hex — falls to
/// the dynamic row exactly as written, no casing applied.
@Test func garbageFallsToTheDynamicRowVerbatim() {
let match = ColorComboModel.match(role: .background, value: "chartreuse")
#expect(match == .current(swatchValue: "chartreuse", title: "chartreuse"))
}
/// The three names shared by both tables (`obsidian`, `aluminum`, `chalk`) are in *both*
/// roles' own palettes, so they match directly and never reach the "foreign name" branch.
@Test func namesSharedByBothTablesMatchDirectlyInEitherRole() {
#expect(ColorComboModel.match(role: .background, value: "obsidian") == .palette("obsidian"))
#expect(ColorComboModel.match(role: .foreground, value: "obsidian") == .palette("obsidian"))
}
}
// MARK: - Item list composition
struct Menu {
/// None first, a separator, then exactly the role's own entries, in the palette's own order.
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
let menu = ColorComboModel.menu(role: .background, value: nil)
var expected: [ColorComboItem] = [.none, .separator]
expected.append(contentsOf: Palette.backgrounds.map { .palette($0.name) })
expected.append(contentsOf: [.separator, .other])
#expect(menu.items == expected)
}
@Test func foregroundRoleListsTheForegroundTwelveNotTheBackgroundTwelve() {
let menu = ColorComboModel.menu(role: .foreground, value: nil)
let paletteNames = menu.items.compactMap { item -> String? in
if case let .palette(name) = item { return name }
return nil
}
#expect(paletteNames == Palette.foregrounds.map(\.name))
}
/// `Other…` is always last, and there is never more than one dynamic row.
@Test func otherIsAlwaysLast() {
for value in [nil, "obsidian", "carnation", "#123456", "chartreuse"] {
let menu = ColorComboModel.menu(role: .background, value: value)
#expect(menu.items.last == .other)
}
}
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
/// one of the role's own entries.
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
let none = ColorComboModel.menu(role: .background, value: nil)
#expect(!none.items.contains { if case .current = $0 { return true }; return false })
#expect(none.selectedIndex == 0)
#expect(none.items[none.selectedIndex] == .none)
let named = ColorComboModel.menu(role: .background, value: "dark-teal")
#expect(!named.items.contains { if case .current = $0 { return true }; return false })
#expect(named.items[named.selectedIndex] == .palette("dark-teal"))
}
/// A foreign or unresolvable value inserts exactly one dynamic row, immediately before the
/// trailing separator and `Other…`, and it is the checked row.
@Test func dynamicRowAppearsOnlyForAForeignValueAndIsSelected() {
let menu = ColorComboModel.menu(role: .background, value: "#123456")
let dynamicRows = menu.items.filter { if case .current = $0 { return true }; return false }
#expect(dynamicRows.count == 1)
#expect(menu.items[menu.selectedIndex] == .current(swatchValue: "#123456", title: "#123456"))
// Immediately before the trailing separator + Other…
#expect(
Array(menu.items.suffix(3)) ==
[.current(swatchValue: "#123456", title: "#123456"), .separator, .other]
)
}
/// A hex landing exactly on a palette colour selects that named row and adds no dynamic row
/// at all — the same shape as picking the name directly.
@Test func anExactHexMatchProducesNoDynamicRow() {
let byName = ColorComboModel.menu(role: .background, value: "light-cayenne")
let byHex = ColorComboModel.menu(role: .background, value: "#B6071E")
#expect(byName.items == byHex.items)
#expect(byName.selectedIndex == byHex.selectedIndex)
}
}
}