Files
lanework/KanbanTests/VisualAccommodationsTests.swift
T
rzen d5ad21c3da The board wears a picture — background becomes a mapping, and the window chrome follows it under a thin frost
background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 10:15:03 -04:00

305 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
import SwiftUI
import Testing
@testable import Kanban
/// The two seams m11's visual-accommodations card added, and the one it made font-derived
/// (10-accessibility.md ▸ Text scaling & visual accommodations):
///
/// 1. **`BoardMetrics`** — the board strip's geometry as a pure function of the body point size, so
/// "Card face, lane header, and masonry metrics derive from font metrics, so layout survives the
/// largest system text sizes" is a fact a suite can assert rather than something to be verified by
/// eye at three text sizes. Nothing here renders; what is pinned is that the numbers *move*, that
/// they move in the right direction, and that they land on the board's settled figures at the
/// standard 13pt body — which is the whole claim that making the board scale did not redesign it.
/// 2. **`Accommodations`** — Increase Contrast and Reduce Transparency as decisions separable from
/// the drawing, `Motion`'s pattern for its reason (`AnyShapeStyle` is opaque, so a claim about a
/// fill is only testable through a small `Equatable` enum).
/// 3. **`LaneLayoutMath` under a scaled gap** — the no-horizontal-scroll invariant, re-asserted at
/// the text sizes the gap now varies over. It is 03-board-ui.md's headline layout rule and the one
/// thing a font-derived gap could plausibly have broken.
// MARK: - The board's font-derived geometry
@Suite("Board metrics ▸ the standard text size is unchanged")
struct BoardMetricsSettledFiguresTests {
/// The system body font is 13pt at the standard macOS text size, and every multiple in
/// `BoardMetrics` was chosen to reproduce the board's existing numbers there.
///
/// This is the load-bearing test of the whole conversion: the milestone's mandate was to make the
/// board *scale*, not to move it, so a default-text-size board must lay out on exactly the
/// figures it laid out on before. A drift here is a visual regression nothing else would catch.
@Test("Every figure lands on the board's settled number at 13pt")
func theStandardSizeReproducesTheSettledFigures() {
let size: CGFloat = 13
#expect(BoardMetrics.stripGap(bodyPointSize: size) == 12)
#expect(BoardMetrics.laneCornerRadius(bodyPointSize: size) == 10)
#expect(BoardMetrics.lanePlatePadding(bodyPointSize: size) == 6)
#expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) == 44)
#expect(BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: size) == 10)
#expect(BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: size) == 8)
#expect(BoardMetrics.trashHatchSpacing(bodyPointSize: size) == 7)
#expect(BoardMetrics.resizeHandleWidth(bodyPointSize: size) == 12)
#expect(BoardMetrics.resizeHandleOverhang(bodyPointSize: size) == 8)
}
/// The board window's floor is the one 02/03 never named in points but that the host has always
/// carried — 640 × 400, reproduced at the standard size.
@Test("The window minimum reproduces its settled size at 13pt")
func theWindowMinimumIsUnchanged() {
let minimum = BoardMetrics.windowMinimumSize(bodyPointSize: 13)
#expect(minimum.width == 637)
#expect(minimum.height == 403)
}
}
@Suite("Board metrics ▸ everything scales")
struct BoardMetricsScalingTests {
/// The text sizes the suite sweeps: below the standard, the standard, and the range a user who
/// has turned the system text size up actually lands in.
private static let sizes: [CGFloat] = [11, 13, 16, 18, 24, 36]
/// **The whole point of the type.** Every figure grows with the text size — which is what
/// "layout survives the largest system text sizes" means arithmetically: a card's padding cannot
/// stay at 10 points while the title inside it doubles.
///
/// Two claims, because rounding to whole points makes them different claims. **Never smaller**
/// between adjacent sizes — a figure that shrank as the text grew would be a bug — and
/// **strictly larger** across the whole range, which is what separates a real derivation from a
/// fixed point size wearing a function's clothes. Adjacent sizes may legitimately share a value
/// for the smallest figures (0.3 em of a 16pt body and of an 18pt body both round to 5), and
/// that is the rounding doing its job: a hairline has no fractional setting worth having.
@Test("Every metric grows with the body point size")
func everyMetricIsMonotone() {
let metrics: [(String, (CGFloat) -> CGFloat)] = [
("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
("cardReplicaWidth", { BoardMetrics.cardReplicaWidth(bodyPointSize: $0) }),
("replicaPadding", { BoardMetrics.replicaPadding(bodyPointSize: $0) }),
("trashHeaderHorizontalPadding", { BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: $0) }),
("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
]
for (name, metric) in metrics {
for (smaller, larger) in zip(Self.sizes, Self.sizes.dropFirst()) {
#expect(metric(smaller) <= metric(larger),
"\(name) shrank from \(smaller)pt to \(larger)pt")
}
#expect(metric(Self.sizes.first!) < metric(Self.sizes.last!),
"\(name) is fixed across the whole range")
}
}
/// **The new-card button's reserve stays ahead of the header's own furniture.**
///
/// 03-board-ui.md's graceful-truncation rule says a long lane title truncates rather than
/// colliding with the button, and the reserve is what makes that true. It has to stay wider than
/// a glyph-and-margin at every size, which is the fixed-22pt failure this conversion exists to
/// remove: at 24pt the glyph alone approaches the old reserve.
@Test("The header's button reserve stays wider than the glyph it reserves for")
func theButtonReserveOutgrowsItsGlyph() {
for size in Self.sizes {
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) > size,
"the reserve is narrower than one em at \(size)pt")
#expect(
BoardMetrics.newCardButtonReserve(bodyPointSize: size)
> BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
"the reserve is narrower than the header's own spacing at \(size)pt"
)
}
}
/// The card plate's proportions hold at every size — the stripe stays a stripe rather than
/// becoming a band, and the plate's inset stays wider than the stripe it sits beside (which is
/// what keeps "colouring a card never shifts its title" from becoming "colouring a card eats its
/// title").
@Test("The card plate keeps its proportions at every text size")
func theCardPlateKeepsItsProportions() {
for size in Self.sizes {
let stripe = BoardMetrics.cardStripeWidth(bodyPointSize: size)
let padding = BoardMetrics.cardContentPadding(bodyPointSize: size)
let radius = BoardMetrics.cardCornerRadius(bodyPointSize: size)
#expect(stripe < padding, "the stripe is wider than the plate's inset at \(size)pt")
#expect(stripe < radius, "the stripe is wider than the corner it rounds into at \(size)pt")
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) > 2 * padding + size,
"the nominal height cannot hold one line of title at \(size)pt")
}
}
/// `em` is total and never yields a non-positive length: a zero-width stripe or a zero-height
/// band is a shape SwiftUI is asked to draw and cannot, and the pathological inputs (a
/// degenerate point size, a vanishing multiple) have to land somewhere.
@Test("The unit is floored at one point, whatever it is handed")
func theUnitIsTotal() {
#expect(BoardMetrics.em(0.3, bodyPointSize: 0) == 1)
#expect(BoardMetrics.em(0, bodyPointSize: 13) == 1)
#expect(BoardMetrics.em(-1, bodyPointSize: 13) == 1)
#expect(BoardMetrics.em(0.01, bodyPointSize: 13) == 1)
}
}
// MARK: - The no-horizontal-scroll invariant, under a gap that moves
@Suite("Board metrics ▸ the strip still never scrolls")
struct ScaledStripDivisionTests {
/// **The invariant 03-board-ui.md § Layout — full visibility is built on, re-checked now that
/// the gap varies with the text size.**
///
/// The whole strip — `totalUnits` standard widths plus `totalUnits + 1` gaps — must fill the
/// window's width exactly, at every text size and every lane count. A larger text size therefore
/// buys a larger gap out of the lanes' own width: the lanes compress, the strip does not grow,
/// and horizontal scroll never appears. That is "the degenerate case is accepted, not floored",
/// holding through the scaling change rather than despite it.
@Test("The lanes plus the gaps fill the window exactly at every text size")
func theDivisionIsExactAtEveryTextSize() {
let stripWidth: CGFloat = 1400
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let gap = BoardMetrics.stripGap(bodyPointSize: size)
for units in 1...12 {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth, totalUnits: units, gap: gap)
let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
#expect(abs(drawn - stripWidth) < 0.001,
"\(units) units at \(size)pt drew \(drawn) into \(stripWidth)")
}
}
}
/// The same window and the same lane count: a **larger** text size means **narrower** lanes,
/// never a wider strip. This is the direction the invariant depends on — the alternative would be
/// a strip that overflowed its window and had to scroll.
@Test("A larger text size narrows the lanes rather than widening the strip")
func aLargerTextSizeCompressesTheLanes() {
let stripWidth: CGFloat = 1400
var previous = CGFloat.greatestFiniteMagnitude
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
totalUnits: 5,
gap: BoardMetrics.stripGap(bodyPointSize: size)
)
#expect(standard < previous, "lanes did not compress at \(size)pt")
previous = standard
}
}
}
// MARK: - Increase Contrast
@Suite("Accommodations ▸ Increase Contrast")
struct IncreaseContrastTests {
/// "Increase Contrast strengthens borders and the selection indicator" (10-accessibility.md), as
/// one flat point rather than a factor — see `Accommodations.borderWidth` for why.
@Test("A stroke goes one point heavier under Increase Contrast")
func aStrokeGoesHeavier() {
for base in [1.0, 1.5, 2.0, 2.5, 3.0] as [CGFloat] {
#expect(Accommodations.borderWidth(base, contrast: .standard) == base)
#expect(Accommodations.borderWidth(base, contrast: .increased) == base + 1)
}
}
/// The widths encode a hierarchy — a hovered card reads heavier than a selected one, a selected
/// tile heavier than an unselected one — and the setting must not flatten it. A flat addend is
/// order-preserving; a multiplier applied to only some sites would not have been.
@Test("Strengthening preserves the hierarchy the widths encode")
func strengtheningIsOrderPreserving() {
let ordered: [CGFloat] = [1, 1.5, 2, 2.5, 3]
for contrast in [ColorSchemeContrast.standard, .increased] {
let strengthened = ordered.map { Accommodations.borderWidth($0, contrast: contrast) }
#expect(strengthened == strengthened.sorted(), "the order collapsed under \(contrast)")
#expect(Set(strengthened).count == ordered.count, "two widths merged under \(contrast)")
}
}
/// The half that is easy to miss: a card plate and a lane plate carry **no** resting border, so
/// "this is one card and that is another" is carried by a fill boundary alone — exactly the
/// distinction the setting exists to rescue. Under Increase Contrast they gain one.
@Test("Resting plates gain an outline only under Increase Contrast")
func restingPlatesGainAnOutline() {
#expect(!Accommodations.drawsRestingBorder(contrast: .standard))
#expect(Accommodations.drawsRestingBorder(contrast: .increased))
}
/// Alpha is the other way a border can be weak. A width bump alone would leave the marquee's
/// 50%-alpha edge and the drag shadow's 55%-alpha dashes just as hard to see, two points wider.
@Test("A faded accent goes to full strength under Increase Contrast")
func afadedAccentGoesFull() {
for base in [0.5, 0.55, 0.6] {
#expect(Accommodations.accentOpacity(base, contrast: .standard) == base)
#expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
}
}
}
// MARK: - Reduce Transparency
@Suite("Accommodations ▸ Reduce Transparency")
struct ReduceTransparencyTests {
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
/// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
/// the rule is one rule: both take the same solid, whatever their weights without it.
@Test("Both glass underlays go solid")
func glassGoesSolid() {
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
#expect(Accommodations.frost(reduceTransparency: false) == .frost)
#expect(Accommodations.frost(reduceTransparency: true) == .solid)
}
/// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
/// they fail the same way for the same user, because what is behind them is a colour the *user*
/// chose (03-board-ui.md § Styling). Each one goes opaque under the setting, and each keeps its
/// own weight without it: the trash header is heavier than its plate, because the header is the
/// whole of "you are looking at the trash".
@Test("Every translucent wash goes opaque, and keeps its weight otherwise")
func washesGoOpaque() {
#expect(Accommodations.trashPlateWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.trashHeaderWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.dragShadowWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.trashPlateWash(reduceTransparency: false) == .translucent(opacity: 0.35))
#expect(Accommodations.trashHeaderWash(reduceTransparency: false) == .translucent(opacity: 0.5))
#expect(Accommodations.dragShadowWash(reduceTransparency: false) == .translucent(opacity: 0.5))
}
/// The trash column's two surfaces are a pair, and the pair has to stay legible as one: the
/// header must read as heavier than the plate under it, or the column stops announcing itself.
@Test("The trash header stays heavier than its plate")
func theTrashHeaderStaysHeavier() {
guard case let .translucent(header) = Accommodations.trashHeaderWash(reduceTransparency: false),
case let .translucent(plate) = Accommodations.trashPlateWash(reduceTransparency: false)
else {
Issue.record("both washes should be translucent without the setting")
return
}
#expect(header > plate)
}
}