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
This commit is contained in:
2026-08-07 11:22:02 -04:00
parent d5ad21c3da
commit cfee4a4b41
29 changed files with 1491 additions and 101 deletions
+99 -2
View File
@@ -87,6 +87,36 @@ private func makeFixture(lanes laneCount: Int = laneCount, cards cardsPerLane: I
// MARK: - Hosting
/// `BoardWindowHost`'s one relevant job, reproduced: read the app-wide zoom level and inject it as
/// the strip's ruler (03-board-ui.md Layout zoom).
///
/// A wrapper rather than a `.environment(\.boardZoom, )` on the hosted root, because the modifier's
/// argument is evaluated once when the root value is built and an `NSHostingView`'s root is a stored
/// value. Reading `appModel.zoom` inside a `body` is what makes a level change re-run this view and
/// therefore re-publish the environment which is exactly the propagation path the real window uses,
/// and exactly what the zoom invariant below is a claim about.
private struct ZoomedBoard: View {
let store: BoardStore
let window: @MainActor () -> NSWindow?
let confirmations: TrashConfirmations
let openCard: (ItemID) -> Void
let search: BoardSearchPresentation
@Environment(AppModel.self) private var appModel
var body: some View {
BoardView(
store: store,
window: window,
confirmations: confirmations,
openCard: openCard,
search: search
)
.environment(\.boardZoom, appModel.zoom.context)
}
}
/// Everything the hosted board needs to stay alive for the length of a test an `NSHostingView`
/// whose window is released the moment nothing holds it would stop rendering mid-measurement.
@MainActor
@@ -97,19 +127,26 @@ private final class HostedBoard {
let view: NSView
private let scratch: URL
/// The preferences domain the model's app-wide state persists into redirected for
/// `registryStorageURL`'s reason. The zoom level lives here, and a suite that used `.standard`
/// would leave the developer's own boards zoomed.
private let preferencesDomain: String
init(store: BoardStore, scratch: URL) {
self.store = store
self.scratch = scratch
preferencesDomain = "dev.rzen.indie.Kanban.render-perf.\(UUID().uuidString)"
appModel = AppModel(
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true)
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
preferences: UserDefaults(suiteName: preferencesDomain)!
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
styleMask: [.titled], backing: .buffered, defer: false
)
self.window = window
let root = BoardView(
let root = ZoomedBoard(
store: store,
window: { [weak window] in window },
confirmations: TrashConfirmations(),
@@ -131,6 +168,7 @@ private final class HostedBoard {
window.orderOut(nil)
window.contentView = nil
try? FileManager.default.removeItem(at: scratch)
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
}
/// Pumps the main run loop until SwiftUI has flushed its pending updates and laid the tree out.
@@ -359,4 +397,63 @@ struct BoardRenderPerformanceTests {
// this card's. See RENDER-INSTRUMENTATION.md What the first run found.
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
}
/// **The zoom feature's load-bearing render claim** (03-board-ui.md Layout zoom).
///
/// `CardFaceView` is `.equatable()` and its `==` compares nothing that moves with the zoom level:
/// the card, its role, its store, the marquee and the drop context all identical across a
/// +. The level reaches the faces *only* because it travels in the environment, which
/// `CardFaceView`'s own note says the gate deliberately does not compare ("SwiftUI invalidates on
/// those itself").
///
/// That makes this the one assertion standing between the feature and a board that zooms its
/// lanes while every card face stays 13pt a failure that would look like a rendering glitch and
/// actually be an architecture decision quietly coming undone. A zero here is the whole bug.
@Test("A zoom change repaints the card faces, through the equality gate")
func zoomRepaintsTheCardFaces() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
BoardRenderMetrics.reset()
board.appModel.zoom.step(.in)
board.settle()
let zoomed = Cost()
print("── zoom in one rung — \(zoomed.summary)")
#expect(board.appModel.zoom.level != BoardZoom.actualSize, "the level did not actually move")
#expect(zoomed.cards > 0, "a zoom change repainted no card faces — the equality gate swallowed it")
#expect(zoomed.containers > 0, "a zoom change repainted no lanes")
#expect(zoomed.strips >= 1, "the strip did not re-run for a zoom change")
// The masonry's own cache is keyed on column width, which moves with the card spacing, so a
// level change must also cost a re-measure rather than replay stale heights.
#expect(zoomed.measures > 0, "the masonry replayed heights measured on the old ruler")
}
/// The counter-invariant: Actual Size costs what it always did. A board nobody zooms must not pay
/// for the feature existing the steady state after a reload is still the numbers the two
/// invariants above pin, with the ruler sitting on the system's own body size.
@Test("At Actual Size the strip renders on the system's own ruler")
func actualSizeIsTheUntouchedBoard() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
#expect(board.appModel.zoom.isActualSize, "a fresh domain must open unzoomed")
#expect(board.appModel.zoom.context.bodyPointSize == BoardMetrics.bodyPointSize)
BoardRenderMetrics.reset()
board.appModel.zoom.step(.actualSize)
board.settle()
let resettled = Cost()
print("── Actual Size when already there — \(resettled.summary)")
// `BoardZoomStore.setLevel` refuses an unchanged level outright `@Observable` notifies on
// every set, equal or not, so without that guard re-asserting the level the board is already
// at would re-run the whole strip to draw exactly what it was drawing.
#expect(resettled.cards == 0, "a no-op Actual Size repainted \(resettled.cards) card faces")
#expect(resettled.containers == 0, "a no-op Actual Size repainted \(resettled.containers) lanes")
}
}
+428
View File
@@ -0,0 +1,428 @@
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()))
}
}
+46 -9
View File
@@ -121,27 +121,64 @@ struct DragAutoScrollMathTests {
// MARK: Engagement reach
/// The standard system body size the ruler every figure below was tuned against.
private static let standardBody: CGFloat = 13
@Test("Engagement reaches over the header but barely sideways")
func engagementReach() {
let viewport = CGSize(width: 240, height: 400)
let reach = DragAutoScrollMath.engagementRect(viewport: viewport)
let size = Self.standardBody
let above = DragAutoScrollMath.reachAbove(bodyPointSize: size)
let below = DragAutoScrollMath.reachBelow(bodyPointSize: size)
let side = DragAutoScrollMath.reachSide(bodyPointSize: size)
let reach = DragAutoScrollMath.engagementRect(viewport: viewport, bodyPointSize: size)
#expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always")
// Above it (the lane header) and below it (the strip's padding).
#expect(reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove + 1)))
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1)))
#expect(reach.contains(CGPoint(x: 120, y: -above + 1)))
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + below - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: -above - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + below + 1)))
// Sideways: only a sliver, so the neighbouring lane never engages.
#expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200)))
#expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200)))
#expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 1, y: 200)))
#expect(reach.contains(CGPoint(x: -side + 1, y: 200)))
#expect(!reach.contains(CGPoint(x: -side - 1, y: 200)))
#expect(!reach.contains(CGPoint(x: viewport.width + side + 1, y: 200)))
// The sideways reach must stay under half the distance between two lanes' scroll areas, or
// two lanes would scroll at once.
#expect(DragAutoScrollMath.reachSide < 28 / 2)
#expect(side < 28 / 2)
}
@Test("The standard body size draws the reaches it always drew")
func reachesAtStandardBodySize() {
let size = Self.standardBody
#expect(DragAutoScrollMath.reachAbove(bodyPointSize: size) == 48)
#expect(DragAutoScrollMath.reachBelow(bodyPointSize: size) == 24)
#expect(DragAutoScrollMath.reachSide(bodyPointSize: size) == 12)
}
/// The reaches are distances to the lane's own furniture a header, a padding, a gap and all
/// three of those grow with the board's zoom (03-board-ui.md Layout zoom). A reach fixed in
/// points would stop covering the header it is specified against.
@Test("Every reach grows with the board's ruler")
func reachesFollowTheRuler() {
let standard = Self.standardBody
let zoomed = BoardZoom.bodyPointSize(system: standard, level: 2.0)
#expect(DragAutoScrollMath.reachAbove(bodyPointSize: zoomed)
> DragAutoScrollMath.reachAbove(bodyPointSize: standard))
#expect(DragAutoScrollMath.reachBelow(bodyPointSize: zoomed)
> DragAutoScrollMath.reachBelow(bodyPointSize: standard))
#expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed)
> DragAutoScrollMath.reachSide(bodyPointSize: standard))
// And the sideways rule holds on the zoomed ruler too: half the distance between two lanes'
// scroll areas is the gap plus a plate padding on each side.
let gap = BoardMetrics.stripGap(bodyPointSize: zoomed)
let padding = BoardMetrics.lanePlatePadding(bodyPointSize: zoomed)
#expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) <= (gap + 2 * padding) / 2)
}
// MARK: Stepping the offset
+1 -1
View File
@@ -661,7 +661,7 @@ struct CardSlotTests {
struct FileDropZoneTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
private let nominal = LaneDropRegistry.nominalCardHeight
private let nominal = LaneDropRegistry().nominalCardHeight
private func landing(
_ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil,
+20 -3
View File
@@ -837,7 +837,12 @@ struct UndoCommandSurfaceTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
let controller = BoardToolbar.controller(
store: store,
search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
session: DragSession()
)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
let (window, hosted) = hostedWindow(manager)
@@ -888,7 +893,14 @@ struct UndoCommandSurfaceTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let toolbar = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
let zoomDomain = "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)"
defer { UserDefaults.standard.removePersistentDomain(forName: zoomDomain) }
let toolbar = BoardToolbar.controller(
store: store,
search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!),
session: DragSession()
)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
let (window, hosted) = hostedWindow(manager)
@@ -928,7 +940,12 @@ struct UndoCommandSurfaceTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
let specs = BoardToolbar.specs(
store: store,
search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
session: DragSession()
)
// Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A
// spec-level `isEnabled` here would be a second answer able to disagree with the responder
+4 -4
View File
@@ -96,7 +96,7 @@ struct RestingLayoutCacheTests {
let layout = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
let nominal = LaneDropRegistry.nominalCardHeight
let nominal = registry.nominalCardHeight
#expect(layout?.heights == [40, nominal, nominal])
}
@@ -194,7 +194,7 @@ struct RestingLayoutRegroundingTests {
// uncached path left it.
let reground = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
#expect(reground?.cardIDs == [card1, foreignCard, card2, card3])
#expect(reground?.heights == [40, LaneDropRegistry.nominalCardHeight, 60, 80])
#expect(reground?.heights == [40, registry.nominalCardHeight, 60, 80])
#expect(cache.builds == 2)
#expect(cache.reuses == 0)
}
@@ -288,7 +288,7 @@ struct RestingLayoutMeasurementTests {
registry.removeHeight(card3)
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
== [40, 60, LaneDropRegistry.nominalCardHeight])
== [40, 60, registry.nominalCardHeight])
#expect(cache.builds == 2)
// A second removal of the same card is not a change, and must not cost a rebuild.
@@ -312,7 +312,7 @@ struct RestingLayoutMeasurementTests {
#expect(cache.layout(inLane: lane1, of: store, registry: settled, hidden: [])?.heights
== [40, 60, 80])
let nominal = LaneDropRegistry.nominalCardHeight
let nominal = opening.nominalCardHeight
#expect(cache.layout(inLane: lane1, of: store, registry: opening, hidden: [])?.heights
== [nominal, nominal, nominal])
#expect(cache.builds == 2)
+122 -16
View File
@@ -31,6 +31,28 @@ private extension Array where Element == ToolbarItemSpec {
}
}
/// 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 (`StyleRecents`' injection, for its reason).
@MainActor
private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
let name = "dev.rzen.indie.Kanban.toolbar-tests.\(UUID().uuidString)"
let store = BoardZoomStore(defaults: UserDefaults(suiteName: name)!)
store.setLevel(level)
return store
}
/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom
/// store and a drag session with nothing in flight.
@MainActor
private func boardSpecs(
store: BoardStore,
search: BoardSearchPresentation = BoardSearchPresentation(),
zoom: BoardZoomStore? = nil,
session: DragSession = DragSession()
) -> [ToolbarItemSpec] {
BoardToolbar.specs(store: store, search: search, zoom: zoom ?? makeZoom(), session: session)
}
// MARK: - The vocabulary
/// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md Toolbar)
@@ -86,18 +108,21 @@ struct BoardToolbarTests {
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
}
@Test("The catalog is 03's five commands plus the field — and the board popover is not in it")
@Test("The catalog is 03's seven commands plus the field — and the board popover is not in it")
func catalogIsTheDesignsInventory() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
let specs = boardSpecs(store: store)
// "Catalog (available via Customize): New Card, New Lane, Undo, Redo , Show Trash" plus
// the search field, which is a catalog item too (a user who removes it can put it back).
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out , Undo, Redo ,
// Show Trash" plus the search field, which is a catalog item too (a user who removes it can
// put it back).
#expect(specs.map(\.identifier) == [
.boardNewCard,
.boardNewLane,
.boardZoomIn,
.boardZoomOut,
.boardUndo,
.boardRedo,
.boardShowTrash,
@@ -106,7 +131,86 @@ struct BoardToolbarTests {
// "The board popover deliberately has no toolbar item the window-title widget is its
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
// pinned by the exact-inventory assertion above and stated again here.
#expect(specs.count == 6)
#expect(specs.count == 8)
}
/// The zoom pair is catalog-only "the titlebar's default stays the search field alone"
/// (03-board-ui.md Toolbar). Stated separately from the default-set test because this is the
/// claim a future item is most likely to break by helpfully adding itself.
@Test("Zoom In and Zoom Out are available but never default")
func zoomIsCatalogOnly() {
#expect(!BoardToolbar.defaultItems.contains(.boardZoomIn))
#expect(!BoardToolbar.defaultItems.contains(.boardZoomOut))
}
/// There is deliberately no Actual Size item: it is a menu row only. Two buttons are the whole
/// of what a toolbar can usefully offer for a ladder with no readout a third that resets is
/// titlebar clutter for a chord.
@Test("Actual Size has no toolbar item")
func actualSizeIsMenuOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(!boardSpecs(store: store).contains { $0.label == "Actual Size" })
}
/// Each button mirrors its menu row's ladder end, which is the same predicate `ZoomCommands`
/// disables on one answer, two faces (03-board-ui.md Toolbar).
@Test("The zoom buttons disable at their own end of the ladder")
func zoomButtonsMirrorTheLadderEnds() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Every collaborator a spec's `isEnabled` interrogates lives in a local for the whole
// test, exactly as `store` already does: the closures capture them weakly production
// hands them app-lived objects so a temporary reads as uniformly disabled.
let session = DragSession()
let topZoom = makeZoom(level: BoardZoom.levels.last!)
let top = boardSpecs(store: store, zoom: topZoom, session: session)
#expect(try !#require(top.spec(.boardZoomIn)).isEnabled)
#expect(try #require(top.spec(.boardZoomOut)).isEnabled)
let bottomZoom = makeZoom(level: BoardZoom.levels.first!)
let bottom = boardSpecs(store: store, zoom: bottomZoom, session: session)
#expect(try #require(bottom.spec(.boardZoomIn)).isEnabled)
#expect(try !#require(bottom.spec(.boardZoomOut)).isEnabled)
let middleZoom = makeZoom()
let middle = boardSpecs(store: store, zoom: middleZoom, session: session)
#expect(try #require(middle.spec(.boardZoomIn)).isEnabled)
#expect(try #require(middle.spec(.boardZoomOut)).isEnabled)
}
/// Both are push buttons, and both go dead while a drag is in flight the menu rows' guard,
/// reached through the very same predicate (`ZoomCommands.isEnabled`).
@Test("The zoom buttons are push buttons, and hold shut mid-drag")
func zoomButtonsHoldShutMidDrag() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Held in locals for the reason `zoomButtonsMirrorTheLadderEnds` spells out and the
// dragging half asks with the same live zoom, so its disabled answer is the drag guard's
// rather than a dead weak reference's.
let zoom = makeZoom()
let restingSession = DragSession()
let resting = boardSpecs(store: store, zoom: zoom, session: restingSession)
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
let spec = try #require(resting.spec(identifier))
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
#expect(spec.isEnabled)
}
let session = DragSession()
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
session.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
container: .board, source: store)
let dragging = boardSpecs(store: store, zoom: zoom, session: session)
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
#expect(try !#require(dragging.spec(identifier)).isEnabled)
}
}
@Test("Every label is its menu row's title, and Undo/Redo keep static ones")
@@ -114,9 +218,11 @@ struct BoardToolbarTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
let specs = boardSpecs(store: store)
#expect(specs.map(\.label) == ["New Card", "New Lane", "Undo", "Redo", "Show Trash", "Search"])
#expect(specs.map(\.label) == [
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Search",
])
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card'), which a toolbar
// label doesn't track". They are also the two items with no action of their own: nil target,
@@ -137,7 +243,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
for spec in boardSpecs(store: store) {
guard let symbol = spec.symbol else { continue }
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
}
@@ -151,13 +257,13 @@ struct BoardToolbarTests {
defer { empty.tearDown() }
let store = try BoardStore(rootURL: populated.root)
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
let specs = boardSpecs(store: store)
let newCard = try #require(specs.spec(.boardNewCard))
#expect(newCard.isEnabled)
#expect(newCard.isOn == nil, "New Card is a push button, not a toggle")
let emptyStore = try BoardStore(rootURL: empty.root)
let emptySpecs = BoardToolbar.specs(store: emptyStore, search: BoardSearchPresentation())
let emptySpecs = boardSpecs(store: emptyStore)
let disabled = try #require(emptySpecs.spec(.boardNewCard))
#expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item")
#expect(emptyStore.newCardTarget == nil, "one predicate, read by both")
@@ -169,7 +275,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search)
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
// 03's three customization sentences: "right-click Customize Toolbar, drag to rearrange,
// system overflow and icon/text display options".
@@ -181,7 +287,7 @@ struct BoardToolbarTests {
#expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers")
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems)
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
for spec in boardSpecs(store: store) {
let item = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: spec.identifier,
@@ -198,7 +304,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search)
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
#expect(search.focusField == nil, "nothing to focus until the item exists")
@@ -233,7 +339,7 @@ struct BoardToolbarTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), session: DragSession())
let item = try #require(controller.toolbar(
controller.toolbar,
@@ -267,7 +373,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search)
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
_ = controller.toolbar(
controller.toolbar,
@@ -290,7 +396,7 @@ struct BoardToolbarTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
let specs = boardSpecs(store: store)
let showTrash = try #require(specs.spec(.boardShowTrash))
#expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark")