A card whose `hero:` names one of its own attachments draws that picture as a banner across the full width of its plate, above the icon-and-title row, aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and em-scaled like every other figure the board draws, so it grows with the system text size and with the board's zoom rather than shrinking against a title twice its usual size. The figure sits deliberately under the 44pt a plain one-line card is tall: a hero card should read as a card with a picture on it rather than a picture with a caption, which is 03's standing rule that the title dominates. The key's grammar is a **bare filename**, and that is what separates it from the board background's `image` subkey rather than a nervousness about paths. A board names a file anywhere under its root, so a path is that key's reading and where it leads is the renderer's question. A card names one of the files it already owns — the flat `attachments/` folder the app lists, relocates into, and carries through every move, copy, trash and restore — so `hero: art/sketch.png` is not an awkward spelling of a hero image, it is a value the key cannot mean. It therefore has no reading at all: a value carrying a separator, or spelling `.`/`..`, or empty, is malformed at the document layer, which renders it as absent and leaves the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written, the resolver re-checks containment anyway, and the whole degrade family below that — a name pointing at a missing file, an unreadable one, or one that is not an image — ends the same way: no banner, no defect, nothing written. That last promise is about *height* as much as about ink, so the band is given no height at all until a picture has actually decoded. A card whose hero cannot be drawn lays out identically to a card with no key, structurally rather than by a branch somebody has to remember; the price is one settle per hero as a board opens, and none after that. Everything else the face draws is attached outside the new stack and is untouched by it — the accent stripe still runs the plate's full leading edge across the band's corner, the selection and file-hover strokes still ring the whole plate, the cut and drag dims still cover it, and the drop model still registers the plate's real height, so a hero card is simply a taller card the masonry already understands. The trash draws it too, by the one-face rule. Decoding is ImageIO's downsampling path off the main actor at a quarter of the backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter rather than being copied), and the results live in one app-wide, deliberately non-observable cache keyed on path plus the file's date and size. Non-observable because a tracked write there would invalidate every hero face on the board, which is the O(board) invalidation this view was rebuilt once already to shed; each face holds its own picture in view state and seeds it from the cache, which is also what lets the drag replica — whose preview builder is non-escaping and cannot await anything — carry the band at the face's real height. Taking a stamp twice from one URL value turned out to answer with the first read's date and size however many times the bytes had changed, so `stamp(of:)` now drops its cached resource values first; noticing a replacement is the only thing a stamp is for. The face takes the resolved URL as a compared input rather than resolving it, for selected-ness's reason one axis over: resolving needs the card's folder, which a face does not know, and finding it from the snapshot would be a board walk per face. The lane and the trash column each know their own container and compute it once for the whole strip. There is no in-app setter this version — the key is written by hand or by an agent, which is why the guide bumps to v13 with a clause spelling the grammar out beside the other card keys, and why `attachments/` gets the one-line pointer an agent that has just written `` will need. "Set as Hero" from the attachment row is future work, as is the card window and print, which draw the same model and show no banner today. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
431 lines
19 KiB
Swift
431 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) }),
|
|
("cardHeroHeight", { BoardMetrics.cardHeroHeight(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()))
|
|
}
|
|
}
|