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())) } }