Files
lanework/KanbanTests/BoardZoomTests.swift
T
rzen eb950ee881 Remove the lane header's "+" new-card control
The lane header no longer draws a new-card button. Every other
creation path is untouched: File > New Card (Cmd-N), Return on a
selected lane, and double-click on lane empty space.

- LaneView: drop the button and its overlay slot, leaving the
  collapse chevron as the header's one piece of trailing chrome.
- NewCardTarget: drop the button's click-names-target carve-out over
  the Cmd-N target rule (Return on a selected lane is now the only
  direct-target path left).
- AccessibilityPhrases: drop the button's spoken label.
- BoardMetrics: drop its width reserve; laneHeaderTrailingReserve now
  reduces to the collapse chevron's own reserve.
- DESIGN/03, 04, 10, 11: update the lane title-bar inventory, the
  Cmd-N override clause and search-clearing mechanism list, the
  accessibility tree-shape sentence, and the pointer-grammar row that
  described the button.
- Tests: drop the two pinned accessibility-label tests and the
  newCardButtonReserve assertions; retarget the truncation-headroom
  test at the now-solo laneHeaderTrailingReserve; fix stale doc-quote
  comments and the manual accessibility-verification checklist.

Full KanbanTests suite: 2807 tests, 482 suites, all passing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:28:05 -04:00

430 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) }),
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(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()))
}
}