Files
lanework/KanbanTests/ColorSwatchPickerTests.swift
T
rzen f191e5c3ce Two widths, one height — the symbol picker squares to 5:4, colour holds 6:4, and None's slash follows the swatch out of the grid
The owner's follow-up review on the shipped rectangles (Pipeline card
5004c540): height was close, so it stays exactly 2.1em on both controls;
width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5,
symbol 1.25) instead of one shared multiplier. The symbol glyph gets a
small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of
breath, not the old padded ring — applied to the face rect before sizing
and fitting, still centred on the same midpoint. The collapsed colour
swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's
None slash whenever the stored value is nil or unresolvable, rather than
sitting empty. Both mounting anchors (the card sidebar's side-by-side
columns, the board popover beside the rename field) pick up .fixedSize()
so the controls render at their own intrinsic size instead of stretching
into whatever slack an HStack proposal leaves them.

KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift
updated for the per-role widths, the shared-height claim, the glyph
inset rule, and the None-face predicate.
2026-08-09 21:37:29 -04:00

123 lines
6.2 KiB
Swift

import AppKit
import Testing
@testable import Kanban
/// **`ColorSwatchPicker`'s pure pieces** (`ColorSwatchPicker.swift`), the 2026-08-10 rework that
/// retired `ColorComboView`'s `NSMenu` dropdown and its pure model — `ColorComboItem`, `ColorComboMatch`,
/// `ColorComboMenu`, `ColorComboModel.match`/`.menu`/`.displayName`/`.normalizedHex` — wholesale. This
/// file used to hold that model's tests; nothing in it survives the dropdown's removal, so it now
/// tests what replaced it: which palette a role offers, and the popover's own grid geometry.
// MARK: - Role
@Suite("ColorSwatchRole ▸ which palette")
struct ColorSwatchRoleTests {
@Test("Background lists the backgrounds table, foreground the foregrounds table")
func rolesListTheirOwnTable() {
#expect(ColorSwatchRole.background.palette.map(\.name) == Palette.backgrounds.map(\.name))
#expect(ColorSwatchRole.foreground.palette.map(\.name) == Palette.foregrounds.map(\.name))
}
}
// MARK: - Popover geometry
/// **`SymbolPickerLayout`'s pattern, restated for a plain colour grid.** The layout is pure and
/// font-derived (10-accessibility.md's full-relative-scaling rule), so every claim below is
/// assertable without a popover on screen.
@Suite("ColorSwatchPopoverLayout ▸ geometry")
struct ColorSwatchPopoverLayoutTests {
/// How wide `columns` wells and the gaps between them actually draw, at a given text size —
/// `StyleEditorLayoutTests.gridWidth`'s own helper, restated for this layout's fixed seven.
private func gridWidth(bodyPointSize: CGFloat) -> CGFloat {
let columns = ColorSwatchPopoverLayout.columns
let layout = ColorSwatchPopoverLayout.metrics(bodyPointSize: bodyPointSize)
return CGFloat(columns) * layout.wellSide + CGFloat(columns - 1) * layout.wellSpacing
}
@Test("Seven columns — None + sixteen wells fall as 7 + 7 + 3, StyleEditorLayout.popover's own settled fit")
func columnsAreSeven() {
#expect(ColorSwatchPopoverLayout.columns == 7)
// None + Palette.backgrounds (16) is 17 wells; seven columns is the count that keeps the
// last row from overflowing to four rows or wasting a nearly-empty one.
let wellCount = 1 + Palette.backgrounds.count
let rows = (wellCount + ColorSwatchPopoverLayout.columns - 1) / ColorSwatchPopoverLayout.columns
#expect(rows == 3)
}
@Test("The grid's well side matches the symbol popover's own scale-up over the shared base")
func wellSideMatchesTheSymbolPopoversScale() {
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
let color = ColorSwatchPopoverLayout.metrics(bodyPointSize: size)
let symbol = SymbolPickerLayout.metrics(bodyPointSize: size)
// Both scale `StyleEditorLayout.wellSide` by the identical `gridScale` — the two
// popovers introduced together (2026-08-10) read at one scale.
#expect(color.wellSide == symbol.wellSide, "the two popovers' wells drifted apart at \(size)pt")
#expect(ColorSwatchPopoverLayout.gridScale == SymbolPickerLayout.gridScale)
}
}
@Test("The popover's frame grows with the text, and its wells keep fitting inside it")
func popoverScalesAndFits() {
var previousWidth: CGFloat = 0
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let layout = ColorSwatchPopoverLayout.metrics(bodyPointSize: size)
#expect(layout.popoverWidth > previousWidth, "the popover must widen with the text at \(size)pt")
previousWidth = layout.popoverWidth
let content = layout.popoverWidth - 2 * layout.contentPadding
#expect(gridWidth(bodyPointSize: size) <= content, "the grid overflows its popover at \(size)pt")
}
}
@Test("The popover's settled geometry at the standard text size")
func settledGeometryAtTheStandardBody() {
let layout = ColorSwatchPopoverLayout.metrics(bodyPointSize: 13)
// `StyleEditorLayout`'s own statics at 13pt: wellSpacing 6, sectionSpacing 14. Well side is
// the style editor's own 20pt scaled by `gridScale`, computed the identical way the
// implementation does rather than restated as a literal — `20 * 1.3` is not exactly
// representable in binary floating point, and comparing two independently-rounded literals
// is exactly the kind of thing that can drift a bit apart from the real computation.
#expect(layout.wellSpacing == 6)
#expect(layout.contentPadding == 14)
let expectedSide = (StyleEditorLayout.wellSide(bodyPointSize: 13) * ColorSwatchPopoverLayout.gridScale).rounded()
#expect(layout.wellSide == expectedSide)
#expect(layout.wellSide == 26)
#expect(layout.gridWidth == layout.wellSide * 7 + layout.wellSpacing * 6)
#expect(layout.popoverWidth == layout.gridWidth + layout.contentPadding * 2)
}
}
// MARK: - The collapsed face's None treatment
/// **The owner's 2026-08-10 follow-up, verbatim**: "when 'none' selected the strike-through line
/// should show on the selected color too." `ColorSwatchControl.showsNoneStrike(for:)` is the pure
/// predicate behind that draw — the same lenient "nil or unresolvable both read as no colour" rule
/// `StyleWellFace.Face.color`'s own doc already states for the popover's grid, restated here for the
/// collapsed face so the two surfaces cannot drift apart about what counts as "None."
@Suite("ColorSwatchControl ▸ the None face")
struct ColorSwatchControlNoneFaceTests {
@Test("A nil value shows the None strike")
func nilShowsTheStrike() {
#expect(ColorSwatchControl.showsNoneStrike(for: nil))
}
@Test("A resolvable palette name does not show the strike")
func resolvableNameHidesTheStrike() {
let name = Palette.backgrounds[0].name
#expect(!ColorSwatchControl.showsNoneStrike(for: name))
}
@Test("A resolvable hand-written hex does not show the strike")
func resolvableHexHidesTheStrike() {
#expect(!ColorSwatchControl.showsNoneStrike(for: "#336699"))
}
@Test("An unresolvable stored value shows the strike, the same lenient rule the popover's grid uses")
func unresolvableValueShowsTheStrike() {
#expect(ColorSwatchControl.showsNoneStrike(for: "not-a-real-palette-name"))
}
}