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.
123 lines
6.2 KiB
Swift
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"))
|
|
}
|
|
}
|