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's one /// surviving material is the transient search bar's `.bar` — the design's own example, the card /// face carousel's page dots, died with the carousel (03-board-ui.md § Card face). @Test("The one glass underlay goes solid") func glassGoesSolid() { #expect(Accommodations.underlay(reduceTransparency: false) == .glass) #expect(Accommodations.underlay(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) } }