The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs
Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app escape hatch is ratified in full, reversing 2026-07-29's palette-only rule — the combo's Other… opens the system Colors panel, a pick landing on a palette color stores the name, anything else the hex. Free-picked colors change no contrast story: they land on the same runtime ink computation hand-written hex always got (10 amended to say so; no warning surface is owed). Anchor ownership: the card sidebar's background story is the combo alone — the well grid's background half stays with the other anchors (StyleEditorView gains showsBackground beside showsSymbols; the popover's symbol half already went to its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a panel pick never enters them. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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 twelve, 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 twelve, 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 twelve.
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user