Full relative text scaling per DESIGN/10: BoardMetrics is the board strip's geometry as a pure function of the body point size (CardWindowMetrics' twin) — lane plate/header/band, card corner/stripe/padding, masonry spacing, the drop model's nominal card height, resize-handle geometry, trash hatch pitch, and both window floors all derive from an em; CardFaceMetrics folded in. The two fixed font sizes (welcome brand/glyph) went relative; the toolbar search field is 17 ems like the transient bar's. The no-horizontal-scroll invariant is pinned by test at six text sizes by twelve lane counts. Accommodations is Motion's sibling for the visual settings: Increase Contrast adds a flat point to strokes (monotone, hierarchy-preserving), gives borderless card/lane plates a resting separator hairline, and takes faded accents to full alpha; Reduce Transparency turns the transient search bar's glass solid and does the same for the alpha washes that composite over a user-chosen board background (trash plate, hatched header, drag shadow). Reduce Motion audited — every animated surface already routes through Motion with a reduced variant; no gaps. Full Keyboard Access: the template chooser's tiles were pointer-only — now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space picks, Return stays the sheet's default action, focus names the selection one-way. The board's single tab stop shows its focus ring under FKA (focusEffectDisabled inverts). Style editor verified already conformant. Edge accents verified text-free; trash hatch pitch now font-derived so it still reads as hatching at large text. 1549 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
451 lines
21 KiB
Swift
451 lines
21 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The card window's sidebar sections, reduced to the rules a unit test can hold (05-card-window.md
|
||
/// ▸ The attributes sidebar).
|
||
///
|
||
/// The views are not the point and are not tested. What is tested is the three seams under them,
|
||
/// each of which fails *silently* — the failure mode this file exists for:
|
||
///
|
||
/// - **Details** shows frontmatter the app deliberately does not understand, so nothing downstream
|
||
/// can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The
|
||
/// only check on it is a test that reads a file and says what the rows must be.
|
||
/// - **Style** embeds a shared, selection-aware component in a window that has no selection. An
|
||
/// anchor wired to the wrong target would look completely normal until it restyled something else.
|
||
/// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics:
|
||
/// at the wrong column count the last well in each row is simply unreachable, at no text size
|
||
/// anyone tests by eye.
|
||
|
||
// MARK: - Details ▸ which keys
|
||
|
||
@Suite("Card details ▸ keys")
|
||
struct CardDetailsKeyTests {
|
||
|
||
/// One card carrying every kind of key at once: the schema's own, an agent overlay, four
|
||
/// reserved enhanced-schema names, and a key written twice.
|
||
private static let card = """
|
||
---
|
||
schema: 1
|
||
title: Fix login
|
||
order: 1024
|
||
project: lanework # agent overlay
|
||
labels: [a, b, c]
|
||
assignees:
|
||
- ada
|
||
- grace
|
||
due: 2026-08-01
|
||
remote: {name: origin, branch: main}
|
||
created: 2026-01-01T09:00:00Z
|
||
modified: 2026-02-02T09:00:00Z
|
||
modified-by: claude
|
||
background: mint
|
||
icon: flag
|
||
iconColor: carnation
|
||
project: overlay-rewritten
|
||
---
|
||
Body text.
|
||
|
||
"""
|
||
|
||
@Test("Every unknown key appears, in file order")
|
||
func unknownKeysInFileOrder() throws {
|
||
let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
|
||
|
||
// File order, verbatim — 01-storage-format.md preserves it and the sidebar honors it. The
|
||
// twice-written `project` reads once, and it reads where its *winning* occurrence sits: the
|
||
// effective view a duplicate collapses to (`FrontmatterDocument.parse`, last-wins), which is
|
||
// also the order the file itself takes the moment anything rewrites that key.
|
||
#expect(rows.map(\.key) == ["labels", "assignees", "due", "remote", "project"])
|
||
}
|
||
|
||
@Test("Reserved enhanced-schema keys are ordinary unknown keys in this version")
|
||
func reservedKeysAreShown() throws {
|
||
let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
|
||
|
||
// "`labels`, `assignees`, `due`, `remote`, … are ordinary unknown keys in this version and
|
||
// appear here like any other — no special rendering" (05 ▸ Details). The day they gain
|
||
// meaning they leave this section for a control; until then, hiding them would hide data the
|
||
// file plainly has.
|
||
for reserved in ["labels", "assignees", "due", "remote"] {
|
||
#expect(rows.contains { $0.key == reserved }, "\(reserved) belongs in Details")
|
||
}
|
||
}
|
||
|
||
@Test("Every schema-owned key is excluded — including the ones the sidebar shows elsewhere")
|
||
func schemaKeysAreExcluded() throws {
|
||
let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
|
||
let keys = Set(rows.map(\.key))
|
||
|
||
// The eleven the app owns. `title`, `created`/`modified`/`modified-by` and the three style
|
||
// keys have their own surfaces in this very window (the title field, the date line, the Style
|
||
// section), and `schema`/`order`/`width`/`deleted` are structure — a Details row for any of
|
||
// them would be the same fact stated twice, in a section whose whole premise is "keys the app
|
||
// does not own".
|
||
#expect(keys.isDisjoint(with: FrontmatterKeys.schemaOwned))
|
||
#expect(keys == ["labels", "assignees", "due", "remote", "project"])
|
||
}
|
||
|
||
@Test("A card with no unknown keys has no section at all")
|
||
func theSectionDisappearsWithNothingToShow() throws {
|
||
// The visibility rule, as the seam states it: "shown only when any exist" (05 ▸ Details).
|
||
// Empty rows are the view's whole condition, so this is that condition.
|
||
let plain = "---\nschema: 1\ntitle: Plain\norder: 1024\n---\nBody.\n"
|
||
#expect(CardDetails.rows(of: try FrontmatterDocument.parse(plain)).isEmpty)
|
||
|
||
// Style keys and stamps are not "details" either — a styled, stamped card still has none.
|
||
let styled = """
|
||
---
|
||
schema: 1
|
||
title: Styled
|
||
order: 1024
|
||
background: mint
|
||
icon: flag
|
||
iconColor: carnation
|
||
created: 2026-01-01T09:00:00Z
|
||
modified: 2026-02-02T09:00:00Z
|
||
modified-by: claude
|
||
width: 2
|
||
deleted: 2026-03-03T09:00:00Z
|
||
---
|
||
|
||
"""
|
||
#expect(CardDetails.rows(of: try FrontmatterDocument.parse(styled)).isEmpty)
|
||
}
|
||
|
||
@Test("The rows come off the snapshot's own card, unknown keys and order intact")
|
||
func theSnapshotCarriesTheKeys() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Self.card)
|
||
|
||
// Nothing was added to the loader for this section: `Card.document` is the whole parsed
|
||
// `index.md` and has been since the model existed, so the section reads what the last reload
|
||
// read — no second parse, no directory walk, no freshness rule of its own.
|
||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||
let card = try #require(model.lanes.first?.cards.first)
|
||
|
||
#expect(CardDetails.rows(of: card.document).map(\.key) == ["labels", "assignees", "due", "remote", "project"])
|
||
#expect(CardDetails.rows(of: card.document).first?.value == "[a, b, c]")
|
||
}
|
||
}
|
||
|
||
// MARK: - Details ▸ which text
|
||
|
||
@Suite("Card details ▸ values")
|
||
struct CardDetailsValueTests {
|
||
|
||
private func rows(_ frontmatter: String) throws -> [String: String] {
|
||
let text = "---\nschema: 1\norder: 1024\n\(frontmatter)---\nBody.\n"
|
||
return Dictionary(uniqueKeysWithValues: CardDetails.rows(of: try FrontmatterDocument.parse(text))
|
||
.map { ($0.key, $0.value) })
|
||
}
|
||
|
||
@Test("A one-line value renders as the author's own bytes")
|
||
func singleLineValuesAreVerbatim() throws {
|
||
let values = try rows("""
|
||
plain: lanework
|
||
flow-map: {name: origin, branch: main}
|
||
flow-seq: [a, b, c]
|
||
hex: "#FF8800"
|
||
quoted: "a # b"
|
||
commented: kept # this comment is the line's, not the value's
|
||
stamp: 2026-08-01T09:00:00Z
|
||
number: 2048
|
||
|
||
""")
|
||
|
||
// The rawest honest form: quotes, braces, brackets, a hash inside a quoted scalar — all of it
|
||
// exactly as typed, because a section that exists to show what the app did *not* interpret
|
||
// must not quietly re-serialize it.
|
||
#expect(values["plain"] == "lanework")
|
||
#expect(values["flow-map"] == "{name: origin, branch: main}")
|
||
#expect(values["flow-seq"] == "[a, b, c]")
|
||
#expect(values["hex"] == "\"#FF8800\"")
|
||
#expect(values["quoted"] == "\"a # b\"")
|
||
#expect(values["stamp"] == "2026-08-01T09:00:00Z")
|
||
#expect(values["number"] == "2048")
|
||
// A trailing comment is the *line's*, and dropping it is the engine's own read-side rule —
|
||
// the bytes on disk keep it.
|
||
#expect(values["commented"] == "kept")
|
||
}
|
||
|
||
@Test("A value spanning several lines reads as its text, not as its YAML syntax")
|
||
func multiLineValuesFallBackToTheParsedReading() throws {
|
||
let values = try rows("""
|
||
notes: |
|
||
first line
|
||
second line
|
||
folded: >-
|
||
wrapped
|
||
prose
|
||
block-seq:
|
||
- ada
|
||
- grace
|
||
block-map:
|
||
name: origin
|
||
branch: main
|
||
wrapped-flow: [one,
|
||
two]
|
||
|
||
""")
|
||
|
||
// The raw span of a block scalar carries `|`, `>-` and the continuation indent — YAML syntax
|
||
// the *value* does not have. So these fall back to the engine's own reading, which is the
|
||
// text the author meant rather than the punctuation that encoded it.
|
||
#expect(values["notes"] == "first line\nsecond line")
|
||
#expect(values["folded"] == "wrapped prose")
|
||
#expect(values["block-seq"] == "[ada, grace]")
|
||
#expect(values["block-map"] == "{name: origin, branch: main}")
|
||
#expect(values["wrapped-flow"] == "[one, two]")
|
||
}
|
||
|
||
@Test("A key with no value reads as null rather than as a blank")
|
||
func emptyValuesReadAsNull() throws {
|
||
let values = try rows("""
|
||
sphere:
|
||
explicit: null
|
||
tilde: ~
|
||
empty-string: ""
|
||
only-a-comment: # nothing but this
|
||
space-after: " "
|
||
|
||
""")
|
||
|
||
// A row showing a key and nothing beside it reads as a bug in the app; YAML's own word for
|
||
// the value is the honest thing to draw. An explicitly *empty string* is a different value
|
||
// and keeps its own bytes.
|
||
#expect(values["sphere"] == "null")
|
||
#expect(values["explicit"] == "null")
|
||
#expect(values["tilde"] == "~")
|
||
#expect(values["only-a-comment"] == "null")
|
||
#expect(values["empty-string"] == "\"\"")
|
||
#expect(values["space-after"] == "\" \"")
|
||
}
|
||
|
||
@Test("A duplicated key reads once, with the winning occurrence's value")
|
||
func duplicateKeysCollapseLastWins() throws {
|
||
let document = try FrontmatterDocument.parse("""
|
||
---
|
||
schema: 1
|
||
order: 1024
|
||
project: first
|
||
sphere: home
|
||
project: second
|
||
---
|
||
Body.
|
||
|
||
""")
|
||
let rows = CardDetails.rows(of: document)
|
||
|
||
// 01-storage-format.md's deliberate divergence from strict YAML, surfaced: the section shows
|
||
// what the app *reads*, and the app reads the last occurrence. Two rows for one key would say
|
||
// the card has a value it does not have.
|
||
#expect(rows.map(\.key) == ["sphere", "project"])
|
||
#expect(rows.map(\.value) == ["home", "second"])
|
||
}
|
||
|
||
@Test("Frontmatter the editor cannot address still renders — readable-but-uneditable")
|
||
func anUneditableDocumentStillShowsItsKeys() throws {
|
||
// A whole-frontmatter flow mapping: no key has a line of its own, so `rawValue` has no span
|
||
// to read and every value falls back to the parsed reading. The document refuses *writes*
|
||
// (`uneditableShape`) — this section only reads, so it shows the keys like any other card's.
|
||
let document = try FrontmatterDocument.parse("""
|
||
---
|
||
{schema: 1, order: 1024, project: lanework, labels: [a, b]}
|
||
---
|
||
Body.
|
||
|
||
""")
|
||
#expect(document.uneditableShape != nil)
|
||
#expect(CardDetails.rows(of: document).map(\.key) == ["project", "labels"])
|
||
#expect(CardDetails.rows(of: document).map(\.value) == ["lanework", "[a, b]"])
|
||
}
|
||
|
||
@Test("Nothing a parsed document can hold makes a row throw or vanish")
|
||
func exoticShapesAreLenient() throws {
|
||
// "Values render as plain text, leniently — exotic YAML shapes display best-effort, never
|
||
// error" (05 ▸ Details). Every shape here has *some* row, and none of them is empty: an
|
||
// unreadable value must still say that the key is there, since the raw source outlet is the
|
||
// only way to fix it and the user has to know to go there.
|
||
let values = try rows("""
|
||
nested: {a: {b: [1, 2, {c: d}]}}
|
||
booleans: [true, false, yes, no]
|
||
unicode: "日本語 — ✂ \\u00e9"
|
||
anchored: &a value
|
||
aliased: *a
|
||
colon-in-value: "key: not a key"
|
||
tabbed: "a\\tb"
|
||
big: 123456789012345678901234567890
|
||
exponent: 1.2e+34
|
||
infinity: .inf
|
||
not-a-number: .nan
|
||
dashes: "- not a list"
|
||
|
||
""")
|
||
|
||
#expect(values.count == 12)
|
||
for (key, value) in values {
|
||
#expect(!value.isEmpty, "\(key) rendered as nothing at all")
|
||
}
|
||
// An alias resolves to `.null` in the snapshot's value view, but its *source span* is what
|
||
// the row shows — so an aliased value states the alias rather than lying about being empty.
|
||
#expect(values["aliased"] == "*a")
|
||
#expect(values["infinity"] == ".inf")
|
||
#expect(values["not-a-number"] == ".nan")
|
||
#expect(values["colon-in-value"] == "\"key: not a key\"")
|
||
}
|
||
}
|
||
|
||
// MARK: - Style ▸ the anchor
|
||
|
||
@Suite("Card sidebar ▸ style anchoring")
|
||
struct CardStyleAnchorTests {
|
||
|
||
@Test("The section's target is this window's card, and only this window's card")
|
||
func theTargetIsTheWindowsCard() {
|
||
let card = ItemID(rawValue: Ident.card1)
|
||
|
||
// The card window has no selection and inherits none: "the two embedded anchors need none of
|
||
// this and get none — the card sidebar dismisses with its card's window" (`StyleEditorSession`).
|
||
// A target derived from anything that moves is the one way this anchor could restyle
|
||
// something the user is not looking at.
|
||
#expect(CardStyleSection.target(forCard: card) == .items([card]))
|
||
|
||
// Never the board — the fallback the *selection-aware* anchor takes with nothing selected,
|
||
// and the one this anchor must never reach: a card window styling the whole board would
|
||
// repaint every lane behind it.
|
||
#expect(CardStyleSection.target(forCard: card) != .board)
|
||
}
|
||
|
||
@Test("A card respelled in caps is the same target")
|
||
func theTargetIsKeyedByIdentityNotSpelling() {
|
||
// The window's own key rule (`CardWindowRef`), so the section cannot disagree with the window
|
||
// it sits in about which card it is aimed at.
|
||
let lower = ItemID(rawValue: Ident.card1)
|
||
let upper = ItemID(rawValue: Ident.card1.uppercased())
|
||
#expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper))
|
||
}
|
||
}
|
||
|
||
// MARK: - Style ▸ the anchor's geometry
|
||
|
||
@Suite("Card sidebar ▸ style editor layout")
|
||
struct StyleEditorLayoutTests {
|
||
|
||
/// How wide `columns` wells and the gaps between them actually draw, at a given text size.
|
||
private func gridWidth(columns: Int, bodyPointSize: CGFloat) -> CGFloat {
|
||
CGFloat(columns) * StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
|
||
+ CGFloat(columns - 1) * StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
|
||
}
|
||
|
||
@Test("The popover anchor keeps its settled geometry at the standard text size")
|
||
func thePopoverKeepsItsSettledGeometry() {
|
||
// 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to
|
||
// sit beside a card"). Making the anchor font-derived (10-accessibility.md ▸ Text scaling)
|
||
// must not have moved them at the size they were settled for — the point of the multiples is
|
||
// that the default text size renders exactly what it always did.
|
||
let popover = StyleEditorLayout.popover(bodyPointSize: 13)
|
||
#expect(popover.width == 268)
|
||
#expect(popover.padding == 14)
|
||
#expect(popover.backgroundColumns == 7)
|
||
#expect(popover.symbolColumns == 8)
|
||
#expect(popover.symbolGridMaximumHeight == 168)
|
||
#expect(popover.wellSide == 20)
|
||
#expect(popover.wellSpacing == 6)
|
||
}
|
||
|
||
/// The other half of the same claim: **the popover grows with the text**, so its seven wells
|
||
/// still fit at a large system text size instead of overflowing a frame frozen at 268 points.
|
||
@Test("The popover's frame grows with the text, and its wells keep fitting inside it")
|
||
func thePopoverScales() {
|
||
var previousWidth: CGFloat = 0
|
||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||
let popover = StyleEditorLayout.popover(bodyPointSize: size)
|
||
#expect(popover.width! > previousWidth, "the popover must widen with the text at \(size)pt")
|
||
previousWidth = popover.width!
|
||
|
||
// The column counts are the design's and never move; what has to hold is that they
|
||
// still fit, insets included.
|
||
#expect(popover.backgroundColumns == 7)
|
||
let content = popover.width! - 2 * popover.padding
|
||
#expect(gridWidth(columns: 7, bodyPointSize: size) <= content, "7 wells overflow at \(size)pt")
|
||
#expect(gridWidth(columns: 8, bodyPointSize: size) <= content, "8 wells overflow at \(size)pt")
|
||
}
|
||
}
|
||
|
||
@Test("The sidebar anchor takes the column it is given and adds nothing to it")
|
||
func theSidebarBringsNoGeometryOfItsOwn() {
|
||
let layout = StyleEditorLayout.sidebar(contentWidth: 169, bodyPointSize: 13)
|
||
|
||
// No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack
|
||
// is already inset by a gutter, and insetting twice would narrow the grids for nothing.
|
||
#expect(layout.width == nil)
|
||
#expect(layout.padding == 0)
|
||
// No inner scroller: the sidebar is already a scroll view, and a scroll view inside a scroll
|
||
// view is a scroll view that fights (`CardWindowView`'s rule).
|
||
#expect(layout.symbolGridMaximumHeight == nil)
|
||
}
|
||
|
||
@Test("The grids fit the sidebar at every text size, and waste no room doing it")
|
||
func theGridsFitTheSidebar() {
|
||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||
let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
|
||
let layout = StyleEditorLayout.sidebar(contentWidth: available, bodyPointSize: size)
|
||
|
||
#expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column")
|
||
// Fits — a grid wider than its column puts the last well of every row out of reach of
|
||
// the pointer, at a text size nobody checks by eye.
|
||
#expect(gridWidth(columns: layout.backgroundColumns, bodyPointSize: size) <= available,
|
||
"overflows at \(size)pt")
|
||
// And is maximal: one more well would not have fitted, so the wells are as large a set as
|
||
// the column can show rather than an arbitrary count that happened to be safe.
|
||
#expect(gridWidth(columns: layout.backgroundColumns + 1, bodyPointSize: size) > available,
|
||
"under-packed at \(size)pt")
|
||
// And the count is *stable* across text sizes, because the column and the wells scale on
|
||
// the same ruler — the sidebar is 26 body characters wide and a well is 1.55 body ems,
|
||
// and neither of those ratios moves. Without that the grid would collapse to a strip at
|
||
// a large text size, which is a palette the user can no longer scan.
|
||
#expect(layout.backgroundColumns >= 4, "the grid collapsed to a strip at \(size)pt")
|
||
}
|
||
}
|
||
|
||
@Test("The sidebar's grids are narrower than the popover's, at the standard text size")
|
||
func theSidebarIsTheNarrowerAnchor() {
|
||
// Which is the entire reason this type exists: the popover's 7 wells across do not fit a
|
||
// 26-character column, so an editor with one hard-coded frame could not have both anchors.
|
||
let layout = StyleEditorLayout.sidebar(
|
||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13),
|
||
bodyPointSize: 13
|
||
)
|
||
#expect(layout.backgroundColumns < StyleEditorLayout.popover(bodyPointSize: 13).backgroundColumns)
|
||
#expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette")
|
||
}
|
||
|
||
@Test("A column too narrow for even one well still asks for one")
|
||
func theFitRuleIsTotal() {
|
||
// Total over any width, because the caller is a layout system: a zero proposal during a
|
||
// window's first frame must not produce a grid of zero columns, which is a division by zero
|
||
// waiting in `LazyVGrid`.
|
||
#expect(StyleEditorLayout.columns(fitting: 0, bodyPointSize: 13) == 1)
|
||
#expect(StyleEditorLayout.columns(fitting: -100, bodyPointSize: 13) == 1)
|
||
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide(bodyPointSize: 13),
|
||
bodyPointSize: 13) == 1)
|
||
}
|
||
|
||
@Test("The sidebar's content width is the column minus its two gutters")
|
||
func theContentWidthIsTheColumnMinusItsGutters() {
|
||
for size in [11.0, 13.0, 24.0] as [CGFloat] {
|
||
#expect(
|
||
CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
|
||
== CardWindowMetrics.sidebarWidth(bodyPointSize: size)
|
||
- 2 * CardWindowMetrics.gutter(bodyPointSize: size)
|
||
)
|
||
}
|
||
// 26 characters at half an em: 26 × 0.5 × 16 = 208, the text width the column was sized for.
|
||
#expect(CardWindowMetrics.sidebarContentWidth(bodyPointSize: 16) == 208)
|
||
}
|
||
}
|