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) } } }