import AppKit import Testing @testable import Kanban /// The colour vocabulary `background` and `iconColor` are written in (03-board-ui.md § Styling ▸ /// Capabilities): a kebab-case palette name, or a `#RRGGBB[AA]` hex — and **nil for everything /// else**, which is a rendering instruction, never an error. /// /// The palette tables themselves are pinned by name *and* hex rather than merely counted: they /// are a carried-over design artefact ("the pathfinder's palettes … carry over as the starting /// point"), and their names are what users hand-write into files — a silent rename or a shifted /// hex would change what an existing board renders as. // MARK: - The tables struct PaletteTableTests { @Test func bothTablesHoldTheTwelveNamedColoursTheDesignCarriesOver() { #expect(Palette.foregrounds.map(\.name) == [ "obsidian", "aluminum", "soapstone", "chalk", "carnation", "rich-grapefruit", "smokey-tangerine", "fern", "light-teal", "deep-sky-blue", "pale-violet", "deep-cool-granite", ]) #expect(Palette.backgrounds.map(\.name) == [ "obsidian", "shale", "aluminum", "chalk", "light-cayenne", "light-mocha", "smokey-mocha", "smokey-fern", "dark-teal", "smokey-ocean", "smokey-rich-eggplant", "intense-cool-shale", ]) } @Test func everyPaletteNameResolvesToItsOwnHex() throws { for entry in Palette.foregrounds + Palette.backgrounds { let byName = try #require( Palette.nsColor(for: entry.name), "palette name '\(entry.name)' did not resolve" ) let byHex = try #require( NSColor(paletteHex: entry.hex), "palette hex '\(entry.hex)' for '\(entry.name)' is not parseable" ) #expect(byName == byHex, "'\(entry.name)' resolved to something other than \(entry.hex)") } } /// The three names appearing in both tables are the same colour in each — `obsidian`, /// `aluminum` and `chalk` are one colour with one hex, listed twice because both *pickers* /// offer them. Resolution searches foregrounds first, so a drift would make the same written /// name mean two different things depending on which field it landed in. @Test func namesSharedByBothTablesCarryOneHex() { for name in ["obsidian", "aluminum", "chalk"] { let foreground = Palette.foregrounds.first { $0.name == name }?.hex let background = Palette.backgrounds.first { $0.name == name }?.hex #expect(foreground != nil && foreground == background, "'\(name)' differs between the two tables") } } /// Both tables answer either field: the foreground/background split is what each picker /// offers, not a namespace (`Palette.nsColor(for:)`). A hand-written `background: carnation` /// — a name only the icon-tint table lists — must resolve, not read as garbage. @Test func aNameFromEitherTableResolvesRegardlessOfWhichFieldItCameFrom() { #expect(Palette.nsColor(for: "carnation") != nil) // foregrounds only #expect(Palette.nsColor(for: "intense-cool-shale") != nil) // backgrounds only } } // MARK: - Name matching struct PaletteNameMatchingTests { /// Names are matched **exactly**, kebab-case as the tables spell them. A near-miss degrades /// like any other unknown value rather than being guessed at — the same posture `ItemSymbol` /// takes towards a typo'd symbol name. @Test func nameMatchingIsCaseSensitiveAndExact() { #expect(Palette.nsColor(for: "deep-sky-blue") != nil) #expect(Palette.nsColor(for: "Deep-Sky-Blue") == nil) #expect(Palette.nsColor(for: "DEEP-SKY-BLUE") == nil) #expect(Palette.nsColor(for: "deep sky blue") == nil) #expect(Palette.nsColor(for: "deepskyblue") == nil) #expect(Palette.nsColor(for: " fern") == nil) #expect(Palette.nsColor(for: "fern ") == nil) } /// `color(for:)` folds all three `FieldValue` shapes into one answer, exactly as /// `ItemSymbol.name(_:fallback:)` does: to a renderer, a missing key, a malformed one, and a /// valid-but-unknown name are one case — *there is no colour, so use your default*. @Test func everyUnusableFieldShapeReadsAsNoColour() { #expect(Palette.color(for: .valid("fern")) != nil) #expect(Palette.color(for: .valid("#FF0000")) != nil) #expect(Palette.color(for: FieldValue.missing) == nil) #expect(Palette.color(for: .malformed(raw: "[a, b]")) == nil) #expect(Palette.color(for: .valid("chartreuse")) == nil) } } // MARK: - Hex parsing /// `#RRGGBB[AA]` in **sRGB** — the colour space the digits name, so a value sampled from a /// screenshot renders as the colour the author sampled. struct PaletteHexTests { private func components(_ hex: String) throws -> (CGFloat, CGFloat, CGFloat, CGFloat) { let color = try #require(NSColor(paletteHex: hex), "'\(hex)' did not parse") let srgb = try #require(color.usingColorSpace(.sRGB), "'\(hex)' is not sRGB-convertible") return (srgb.redComponent, srgb.greenComponent, srgb.blueComponent, srgb.alphaComponent) } private func expectComponents( _ hex: String, _ expected: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) ) throws { let (red, green, blue, alpha) = try components(hex) let tolerance: CGFloat = 0.001 #expect(abs(red - expected.red) < tolerance, "\(hex): red \(red) != \(expected.red)") #expect(abs(green - expected.green) < tolerance, "\(hex): green \(green) != \(expected.green)") #expect(abs(blue - expected.blue) < tolerance, "\(hex): blue \(blue) != \(expected.blue)") #expect(abs(alpha - expected.alpha) < tolerance, "\(hex): alpha \(alpha) != \(expected.alpha)") } @Test func sixDigitHexParsesToItsSRGBComponentsAtFullOpacity() throws { try expectComponents("#FF0000", (red: 1, green: 0, blue: 0, alpha: 1)) try expectComponents("#00FF00", (red: 0, green: 1, blue: 0, alpha: 1)) try expectComponents("#0000FF", (red: 0, green: 0, blue: 1, alpha: 1)) try expectComponents("#000000", (red: 0, green: 0, blue: 0, alpha: 1)) try expectComponents("#FFFFFF", (red: 1, green: 1, blue: 1, alpha: 1)) // The rich-board fixture's board background — an ordinary hand-written value. try expectComponents("#1E1E1E", (red: 30 / 255, green: 30 / 255, blue: 30 / 255, alpha: 1)) } @Test func eightDigitHexParsesItsTrailingPairAsAlpha() throws { try expectComponents("#FF000080", (red: 1, green: 0, blue: 0, alpha: 128 / 255)) try expectComponents("#00FF00FF", (red: 0, green: 1, blue: 0, alpha: 1)) try expectComponents("#0000FF00", (red: 0, green: 0, blue: 1, alpha: 0)) } /// Hex digits are case-insensitive — unlike palette *names*. Two different vocabularies with /// two different rules, deliberately: `#ff0000` is the same number as `#FF0000`, where /// `Fern` is simply not a name the palette has. @Test func hexDigitsAreCaseInsensitive() throws { let lower = try #require(NSColor(paletteHex: "#a1b2c3")) let upper = try #require(NSColor(paletteHex: "#A1B2C3")) #expect(lower == upper) } /// Garbage resolves to nothing — no throw, no default colour, no partial read of a truncated /// value. Every one of these leaves the bytes on disk untouched and the surface undecorated. @Test func malformedAndUnknownValuesResolveToNil() { for value in [ "", // the empty string "#", // a lone marker "#12", // too short "#12345", // five digits "#1234567", // seven — neither RGB nor RGBA "#123456789", // nine "#GGGGGG", // right length, not hex "#12345G", // one bad digit "not-a-color", // kebab-case, but not a name the palette has "FF0000", // hex digits without the '#' read as a name, and no name matches "rgb(255,0,0)", // a different colour vocabulary entirely ] { #expect(Palette.nsColor(for: value) == nil, "'\(value)' should not resolve") #expect(Palette.color(named: value) == nil, "'\(value)' should not resolve to a SwiftUI Color") } } }