Files
lanework/KanbanTests/BoardZoomTests.swift
T
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:02 -04:00

429 lines
19 KiB
Swift

import CoreGraphics
import Foundation
import SwiftUI
import Testing
@testable import Kanban
/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that
/// used `.standard` would zoom the developer's own boards.
@MainActor
private func makeStore(seeding stored: Double? = nil) -> (BoardZoomStore, UserDefaults, () -> Void) {
let name = "dev.rzen.indie.Kanban.zoom-tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
if let stored { defaults.set(stored, forKey: AppPreferences.boardZoomLevelKey) }
return (BoardZoomStore(defaults: defaults), defaults,
{ UserDefaults.standard.removePersistentDomain(forName: name) })
}
/// The standard system body size. Written down rather than read, so every claim below is a fact about
/// the arithmetic and not about the machine the suite happens to run on.
private let standardBody: CGFloat = 13
// MARK: - The ladder
@Suite("Zoom ▸ the ladder")
struct BoardZoomLadderTests {
@Test("The rungs ascend, and 100% is one of them")
func rungsAscend() {
#expect(BoardZoom.levels == BoardZoom.levels.sorted())
#expect(Set(BoardZoom.levels).count == BoardZoom.levels.count, "no rung appears twice")
#expect(BoardZoom.levels.contains(BoardZoom.actualSize), "Actual Size must be reachable by stepping")
#expect(BoardZoom.levels.allSatisfy { $0 > 0 })
}
/// A held ⌘+ must settle rather than cycle, so both ends are fixed points.
@Test("Stepping walks the ladder and stops at each end")
func steppingTerminates() {
var level = BoardZoom.levels.first!
for expected in BoardZoom.levels.dropFirst() {
level = BoardZoom.stepIn(from: level)
#expect(level == expected)
}
#expect(BoardZoom.stepIn(from: level) == level, "the top rung is a fixed point")
for expected in BoardZoom.levels.dropLast().reversed() {
level = BoardZoom.stepOut(from: level)
#expect(level == expected)
}
#expect(BoardZoom.stepOut(from: level) == level, "the bottom rung is a fixed point")
}
/// ⌘+ then ⌘− lands back exactly where it started — the whole reason the ladder is discrete
/// rather than a percentage field.
@Test("In then out is a round trip from every interior rung")
func inThenOutRoundTrips() {
for level in BoardZoom.levels.dropFirst().dropLast() {
#expect(BoardZoom.stepOut(from: BoardZoom.stepIn(from: level)) == level)
#expect(BoardZoom.stepIn(from: BoardZoom.stepOut(from: level)) == level)
}
}
@Test("The can-step predicates agree with what stepping actually does")
func predicatesMatchStepping() {
for level in BoardZoom.levels {
#expect(BoardZoom.canZoomIn(level) == (BoardZoom.stepIn(from: level) != level))
#expect(BoardZoom.canZoomOut(level) == (BoardZoom.stepOut(from: level) != level))
}
#expect(!BoardZoom.canZoomIn(BoardZoom.levels.last!))
#expect(!BoardZoom.canZoomOut(BoardZoom.levels.first!))
#expect(BoardZoom.isActualSize(BoardZoom.actualSize))
}
}
// MARK: - Reading a stored level
/// `normalize` is the one gate every persisted level passes through, and the reason it exists is that
/// the preference is a plain `UserDefaults` scalar a user can `defaults write` to anything.
@Suite("Zoom ▸ normalising a stored level")
struct BoardZoomNormalizeTests {
@Test("Every rung survives a round trip")
func rungsAreFixedPoints() {
for level in BoardZoom.levels {
#expect(BoardZoom.normalize(Double(level)) == level)
}
}
/// The trap this exists for: `double(forKey:)` answers 0 for a key nobody ever set, and an
/// unfiltered 0 would drive every `BoardMetrics.em` multiple to its 1pt floor.
@Test("Zero is not a level, and never becomes one")
func zeroIsClamped() {
let level = BoardZoom.normalize(0)
#expect(BoardZoom.levels.contains(level))
#expect(level == BoardZoom.levels.first!)
#expect(BoardZoom.bodyPointSize(system: standardBody, level: level) > 1)
}
@Test("Garbage lands on a legal rung")
func garbageIsClamped() {
#expect(BoardZoom.levels.contains(BoardZoom.normalize(-4)))
#expect(BoardZoom.levels.contains(BoardZoom.normalize(99)))
#expect(BoardZoom.normalize(99) == BoardZoom.levels.last!)
#expect(BoardZoom.normalize(-4) == BoardZoom.levels.first!)
}
/// Not a number has no honest nearest rung, so it resolves to the one answer that cannot
/// surprise.
@Test("Non-finite input resolves to Actual Size")
func nonFiniteIsActualSize() {
#expect(BoardZoom.normalize(.nan) == BoardZoom.actualSize)
#expect(BoardZoom.normalize(.infinity) == BoardZoom.actualSize)
#expect(BoardZoom.normalize(-.infinity) == BoardZoom.actualSize)
}
/// A build that shortened the ladder under a value an older one wrote — snapped, not refused.
@Test("An off-ladder value snaps to its nearest rung")
func offLadderSnaps() {
#expect(BoardZoom.normalize(1.07) == 1.0)
#expect(BoardZoom.normalize(1.12) == 1.15)
#expect(BoardZoom.normalize(1.9) == 2.0)
}
}
// MARK: - What a level means
@Suite("Zoom ▸ what a level means")
struct BoardZoomMeaningTests {
/// The founding guarantee: Actual Size draws pixel-for-pixel what the board drew before zoom
/// existed.
@Test("Actual Size is the system's own ruler, exactly")
func actualSizeIsTheSystemRuler() {
#expect(BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize) == standardBody)
#expect(BoardZoom.bodyPointSize(system: 18, level: BoardZoom.actualSize) == 18)
}
/// The other half of that guarantee: a relative text style stays a relative text style, carrying
/// the system's own leading and traits, until a deliberate zoom trades it away.
@Test("Actual Size hands back the relative style itself")
@MainActor
func actualSizeKeepsRelativeStyles() {
#expect(BoardZoom.font(.body, level: BoardZoom.actualSize) == .body)
#expect(BoardZoom.font(.caption, level: BoardZoom.actualSize) == .caption)
#expect(BoardZoom.font(.headline, level: BoardZoom.actualSize) == .headline)
#expect(BoardZoom.font(.caption2, level: BoardZoom.actualSize) == .caption2)
}
@Test("Off the default rung, the style is scaled rather than kept")
@MainActor
func zoomedStylesAreScaled() {
#expect(BoardZoom.font(.body, level: 2.0) != .body)
#expect(BoardZoom.font(.caption, level: 0.75) != .caption)
}
@Test("The level scales the ruler monotonically")
func levelScalesTheRuler() {
let sizes = BoardZoom.levels.map { BoardZoom.bodyPointSize(system: standardBody, level: $0) }
#expect(sizes == sizes.sorted())
#expect(sizes.first! < standardBody)
#expect(sizes.last! > standardBody)
}
/// Deliberately unrounded: `BoardMetrics.em` already rounds every figure it produces, and
/// rounding here would round twice on two different rulers.
@Test("The ruler is not pre-rounded")
func rulerIsNotRounded() {
#expect(BoardZoom.bodyPointSize(system: 13, level: 1.15) == 13 * 1.15)
}
/// A level cannot be trusted to be positive until `normalize` has seen it, and no proposal
/// downstream may be zero or negative.
@Test("The ruler is floored at one point")
func rulerIsFloored() {
#expect(BoardZoom.bodyPointSize(system: 13, level: 0) == 1)
#expect(BoardZoom.bodyPointSize(system: 13, level: -2) == 1)
}
@Test("The percent label is the announcement's value")
func percentLabel() {
#expect(BoardZoom.percentLabel(1.0) == "100%")
#expect(BoardZoom.percentLabel(1.15) == "115%")
#expect(BoardZoom.percentLabel(0.75) == "75%")
#expect(BoardZoom.percentLabel(2.0) == "200%")
#expect(AccessibilityPhrases.zoomLevel(BoardZoom.percentLabel(1.3)) == "Zoom 130%")
}
}
// MARK: - Every board metric follows the ruler
/// The point of the whole feature: `BoardMetrics` is already parameterised on the body point size, so
/// moving the level moves every figure the strip draws. Asserted over the table rather than per
/// figure, so a metric added later is covered the day it lands.
@Suite("Zoom ▸ the metrics follow the level")
struct BoardZoomMetricsTests {
/// Every em-derived figure on the board, by name — `VisualAccommodationsTests`' own inventory
/// shape, which is how a new metric gets caught by this suite without anyone remembering to add
/// it to two lists.
private var figures: [(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) }),
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
] }
@Test("Zooming in grows every figure; zooming out shrinks every figure")
func figuresFollowTheLevel() {
let resting = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize)
let zoomedIn = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.last!)
let zoomedOut = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.first!)
for (name, figure) in figures {
#expect(figure(zoomedIn) > figure(resting), "\(name) must grow when the board zooms in")
#expect(figure(zoomedOut) < figure(resting), "\(name) must shrink when the board zooms out")
}
}
@Test("Every figure stays a whole point, and at least one, at every rung")
func figuresStayLegalAtEveryRung() {
for level in BoardZoom.levels {
let size = BoardZoom.bodyPointSize(system: standardBody, level: level)
for (name, figure) in figures {
let value = figure(size)
#expect(value >= 1, "\(name) collapsed at \(BoardZoom.percentLabel(level))")
#expect(value == value.rounded(), "\(name) is not a whole point at \(BoardZoom.percentLabel(level))")
}
}
}
/// **The no-horizontal-scroll invariant survives every rung** (03-board-ui.md ▸ Layout). Zoom
/// moves the gap, so the lanes narrow; what it must never do is make the strip want more room
/// than the window has.
@Test("The strip still exactly fills its window at every rung")
func stripStillFillsAtEveryRung() {
let stripWidth: CGFloat = 1400
for level in BoardZoom.levels {
let gap = BoardMetrics.stripGap(
bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: level))
for units in 1...8 {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth, totalUnits: units, gap: gap)
#expect(standard > 0)
let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
#expect(abs(drawn - stripWidth) < 0.001,
"\(units) units at \(BoardZoom.percentLabel(level)) does not fill the strip")
}
}
}
/// Zoom in and the lanes narrow — the honest consequence of a board that refuses horizontal
/// scroll, and the one thing about this feature a user could be surprised by. Pinned so it stays
/// a decision rather than becoming a bug report.
@Test("Zooming in narrows the lanes rather than widening the strip")
func zoomingInNarrowsLanes() {
let stripWidth: CGFloat = 1400
let resting = LaneLayoutMath.standardWidth(
stripWidth: stripWidth, totalUnits: 4,
gap: BoardMetrics.stripGap(bodyPointSize: standardBody))
let zoomed = LaneLayoutMath.standardWidth(
stripWidth: stripWidth, totalUnits: 4,
gap: BoardMetrics.stripGap(
bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: 2.0)))
#expect(zoomed < resting)
// …but only by the gap's growth, which is a few percent — not by the level.
#expect(zoomed > resting * 0.9, "the narrowing is the gap's, not the level's")
}
}
// MARK: - The persisted level
@Suite("Zoom ▸ the persisted level")
@MainActor
struct BoardZoomStoreTests {
@Test("A fresh domain opens at Actual Size")
func freshDomainIsActualSize() {
let (store, _, tearDown) = makeStore()
defer { tearDown() }
#expect(store.level == BoardZoom.actualSize)
#expect(store.isActualSize)
}
@Test("A level survives the trip through defaults")
func levelPersists() {
let (store, defaults, tearDown) = makeStore()
defer { tearDown() }
store.zoomIn()
let level = store.level
#expect(level != BoardZoom.actualSize)
let reopened = BoardZoomStore(defaults: defaults)
#expect(reopened.level == level)
}
/// The stored scalar is a plain preference a user can hand-edit; whatever is in there, the store
/// opens on a rung.
@Test("A hand-edited preference still opens on a rung")
func handEditedPreferenceIsNormalised() {
for stored in [0, -1, 1.07, 42] as [Double] {
let (store, _, tearDown) = makeStore(seeding: stored)
defer { tearDown() }
#expect(BoardZoom.levels.contains(store.level), "\(stored) opened off the ladder")
}
}
@Test("The three moves walk the ladder and hold at its ends")
func movesWalkTheLadder() {
let (store, _, tearDown) = makeStore()
defer { tearDown() }
for _ in BoardZoom.levels { store.zoomIn() }
#expect(store.level == BoardZoom.levels.last!)
#expect(!store.canZoomIn)
for _ in BoardZoom.levels { store.zoomOut() }
#expect(store.level == BoardZoom.levels.first!)
#expect(!store.canZoomOut)
store.actualSize()
#expect(store.level == BoardZoom.actualSize)
#expect(store.isActualSize)
}
/// The single write path the menu rows and the toolbar buttons share, so the two faces of one
/// command can never drift (`BoardStore.setTrashVisible`'s rule).
@Test("The shared step is the same walk")
func sharedStepIsTheSameWalk() {
let (store, _, tearDown) = makeStore()
defer { tearDown() }
store.step(.in)
#expect(store.level == BoardZoom.stepIn(from: BoardZoom.actualSize))
store.step(.out)
#expect(store.level == BoardZoom.actualSize)
store.step(.in)
store.step(.actualSize)
#expect(store.level == BoardZoom.actualSize)
}
@Test("The context carries the level to the strip")
func contextCarriesTheLevel() {
let (store, _, tearDown) = makeStore()
defer { tearDown() }
store.zoomIn()
#expect(store.context.level == store.level)
#expect(store.context == BoardZoomContext(level: store.level))
#expect(BoardZoomContext.actualSize.level == BoardZoom.actualSize)
}
}
// MARK: - The commands' scope
@Suite("Zoom ▸ the commands' scope")
@MainActor
struct ZoomCommandScopeTests {
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
/// The rows are board-scoped: the level is app-wide, but only board windows draw with it, so a
/// ⌘+ over a card window or the welcome screen would be a command that silently missed.
@Test("No board window, no zoom")
func withoutABoardTheRowsAreDead() {
#expect(!ZoomCommands.isEnabled(store: nil, session: DragSession()))
}
@Test("A board window in front, and nothing in flight, is live")
func withABoardTheRowsAreLive() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(ZoomCommands.isEnabled(store: store, session: DragSession()))
}
/// A drag freezes geometry the level feeds — the run's heights, and `RestingLayoutCache`, whose
/// entry key does not include the point size. The guard states that rather than relying on the
/// AppKit drag loop happening to swallow the chord.
@Test("A drag in flight holds all three rows shut")
func aDragHoldsTheRowsShut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
let cards = DragSession()
cards.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
container: .board, source: store)
#expect(!ZoomCommands.isEnabled(store: store, session: cards))
let lanes = DragSession()
lanes.beginLanes([ItemID(rawValue: Ident.lane1)], folders: [folder], units: [1], source: store)
#expect(!ZoomCommands.isEnabled(store: store, session: lanes))
}
/// Zooming is a view change, not a mutation — `ShowTrashCommand`'s rule. So the rows are not
/// gated on `acceptsBoardMutations`, which an open inline editor is enough to falsify: a user
/// naming a new card can still make the board bigger to read it.
@Test("A board that refuses mutations still zooms")
func mutationRefusalDoesNotCloseTheRows() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.lane1))
#expect(!store.acceptsBoardMutations, "the fixture must actually be refusing mutations")
#expect(ZoomCommands.isEnabled(store: store, session: DragSession()))
}
}