A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
703 lines
36 KiB
Swift
703 lines
36 KiB
Swift
import CoreGraphics
|
||
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// Unit tests for the board strip's pure geometry (03-board-ui.md § Layout — full visibility and
|
||
/// § Lane): the resting division of the window across width units, and the right-edge drag's
|
||
/// asymmetric "shadow leads" snap.
|
||
///
|
||
/// Fixture throughout the snap suites: a frozen standard of 100 and a gap of 12 — so a `step`
|
||
/// (`standard + gap`) is 112, and the slot widths are:
|
||
/// 1× = 100 · 2× = 212 · 3× = 324
|
||
/// The tick-up threshold for shadow `k` is `slotWidth(k) + gap` — the far side of the gap trailing
|
||
/// that slot — so 1↔2 ticks up at 112 (100 + 12) and 2↔3 at 224 (212 + 12). The tick-down threshold
|
||
/// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary — so 2↔1
|
||
/// ticks down at 102 (112 − 10) and 3↔2 at 214 (224 − 10).
|
||
///
|
||
/// Where the pathfinder's twin suite pinned a hard 1…3 width cap, these pin the two regimes the
|
||
/// screen fit divides (03-board-ui.md § Lane, settled 2026-08-08): up to the fit a tick grows the
|
||
/// window, past it a tick re-divides the pinned strip, and `allowedRange`'s ceiling is the strip's
|
||
/// own capacity rather than either — the width field itself has no cap. The suites below take a
|
||
/// screen fit as a *range* where the mechanism is not what is under test, and the re-divide gets its
|
||
/// own suite at the foot of the file.
|
||
|
||
private let standard: CGFloat = 100
|
||
private let gap: CGFloat = 12
|
||
private let step: CGFloat = standard + gap // 112
|
||
private let reentry: CGFloat = 10
|
||
|
||
/// A screen that fits three units — the drag's ceiling in most tests below.
|
||
private let fitsThree = 1...3
|
||
|
||
private func slot(_ units: Int) -> CGFloat {
|
||
LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||
}
|
||
|
||
private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange<Int>? = nil) -> Int {
|
||
LaneLayoutMath.snappedUnits(liveWidth: liveWidth, currentUnits: current,
|
||
standard: standard, gap: gap,
|
||
allowedRange: range ?? fitsThree, reentry: reentry)
|
||
}
|
||
|
||
// MARK: - Lane fixtures
|
||
|
||
/// Loads a board whose lanes carry the given `width:` frontmatter values (`nil` writes no key), in
|
||
/// the order given — `order` keys make the display order the argument order.
|
||
///
|
||
/// Built through `BoardLoader` rather than by constructing `Lane` values by hand: `width`'s
|
||
/// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to
|
||
/// ask "what does a malformed width display as" is to put the malformed bytes on disk and load
|
||
/// them.
|
||
/// `collapsed` is the parallel list of `collapsed:` values (`nil` writes no key), padded with `nil`
|
||
/// when it is shorter than `widths` — a board with no folded lane passes none at all and reads exactly
|
||
/// as it did before the key existed.
|
||
private func lanes(widths: [String?], collapsed: [String?] = []) throws -> [Lane] {
|
||
let root = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true)
|
||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||
defer { try? FileManager.default.removeItem(at: root) }
|
||
|
||
try write("---\nschema: 1\ntitle: Board\n---\n", to: root)
|
||
for (index, width) in widths.enumerated() {
|
||
var frontmatter = "---\nschema: 1\ntitle: Lane \(index)\norder: \(1024 * (index + 1))\n"
|
||
if let width {
|
||
frontmatter += "width: \(width)\n"
|
||
}
|
||
if index < collapsed.count, let value = collapsed[index] {
|
||
frontmatter += "collapsed: \(value)\n"
|
||
}
|
||
frontmatter += "---\n"
|
||
let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||
try write(frontmatter, to: folder)
|
||
}
|
||
return try BoardLoader.load(boardRoot: root).model.lanes
|
||
}
|
||
|
||
private func write(_ text: String, to folder: URL) throws {
|
||
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
|
||
}
|
||
|
||
private func lane(width: String?) throws -> Lane {
|
||
let loaded = try lanes(widths: [width])
|
||
return try #require(loaded.first)
|
||
}
|
||
|
||
// MARK: - The resting layout
|
||
|
||
@Suite("LaneLayoutMath ▸ the resting division")
|
||
struct LaneLayoutStandardWidthTests {
|
||
|
||
@Test("The strip divides its width across the units, counting a gap outside each end")
|
||
func standardWidthCountsOuterMargins() {
|
||
// 1000 = 1 lane + 2 outer gaps: (1000 − 24) / 1.
|
||
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 1, gap: 12) == 976)
|
||
// Three units: 4 gaps (2 interior + 2 outer) → (1000 − 48) / 3.
|
||
#expect(abs(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: 12) - 952.0 / 3.0) < 0.0001)
|
||
// A wide lane consumes several units of the same division, and the strip still fills
|
||
// exactly: 4 units of standard plus the gaps is the whole strip.
|
||
let standard = LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 4, gap: 12)
|
||
let filled = 4 * standard + 12 * 5
|
||
#expect(abs(filled - 1000) < 0.0001)
|
||
}
|
||
|
||
@Test("A slot swallows the interior gaps it spans, so lanes and slots are the same pixels")
|
||
func slotWidthSwallowsInteriorGaps() {
|
||
#expect(slot(1) == 100)
|
||
#expect(slot(2) == 212) // 2·100 + 1·12
|
||
#expect(slot(3) == 324) // 3·100 + 2·12
|
||
}
|
||
|
||
@Test("Compression is accepted, not floored — only a 1pt floor keeps frames positive")
|
||
func degenerateStripsCompressWithoutAMinimum() {
|
||
// Twenty units in a small window: each lane is a sliver, and that is the design's answer
|
||
// ("the degenerate case is accepted, not floored"), not a scroll bar.
|
||
let squeezed = LaneLayoutMath.standardWidth(stripWidth: 400, totalUnits: 20, gap: 12)
|
||
#expect(squeezed > 0)
|
||
#expect(squeezed < 10)
|
||
// Narrower than its own gaps: still positive, because a zero or negative frame is a
|
||
// rendering bug rather than a design outcome.
|
||
#expect(LaneLayoutMath.standardWidth(stripWidth: 10, totalUnits: 4, gap: 12) == 1)
|
||
#expect(LaneLayoutMath.standardWidth(stripWidth: 0, totalUnits: 1, gap: 12) == 1)
|
||
}
|
||
|
||
@Test("An empty strip still has a divisor")
|
||
func zeroUnitsIsTreatedAsOne() {
|
||
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 0, gap: 12) == 976)
|
||
#expect(LaneLayoutMath.totalUnits(of: []) == 1)
|
||
}
|
||
}
|
||
|
||
// MARK: - Reading a lane's units
|
||
|
||
@Suite("LaneLayoutMath ▸ display units")
|
||
struct LaneDisplayUnitsTests {
|
||
|
||
@Test("A valid width spans that many units")
|
||
func validWidthSpansItsUnits() throws {
|
||
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "3")) == 3)
|
||
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "1")) == 1)
|
||
// The read side coerces where a sensible reading exists, and the layout follows it.
|
||
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "\"2\"")) == 2)
|
||
}
|
||
|
||
@Test("A missing, malformed, zero or negative width renders as one unit")
|
||
func leniencyRendersAsOne() throws {
|
||
let missing = try lane(width: nil)
|
||
#expect(missing.width.isMissing)
|
||
#expect(LaneLayoutMath.displayUnits(of: missing) == 1)
|
||
|
||
// A fraction or non-numeric text has no integer reading at all — stays `.malformed` on
|
||
// the model (bytes preserved, not corrected) and renders as 1.
|
||
for raw in ["wide", "1.5"] {
|
||
let lane = try lane(width: raw)
|
||
#expect(lane.width.isMalformed, "width: \(raw) should stay malformed rather than coerce")
|
||
#expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit")
|
||
}
|
||
|
||
// An exact integer below 1 is a **different** case (01-storage-format.md § Frontmatter,
|
||
// "ranges are part of the sensible reading", settled): it coerces to `.valid(1)`, not
|
||
// malformed — same on-screen result, different model reading.
|
||
for raw in ["0", "-3"] {
|
||
let lane = try lane(width: raw)
|
||
#expect(lane.width == .valid(1), "width: \(raw) should coerce to 1, not stay malformed")
|
||
#expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit")
|
||
}
|
||
}
|
||
|
||
@Test("The strip's unit total is the sum over the lanes it is given")
|
||
func totalUnitsSumsDisplayUnits() throws {
|
||
let loaded = try lanes(widths: ["2", nil, "3", "banana"])
|
||
#expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 1, 3, 1])
|
||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 7)
|
||
#expect(LaneLayoutMath.totalUnits(of: Array(loaded.prefix(2))) == 3)
|
||
}
|
||
|
||
@Test("There is no upper cap on a lane's width")
|
||
func widthIsUncapped() throws {
|
||
// 03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap". A wide lane simply takes more of the
|
||
// division; nothing clamps it on the way in.
|
||
let wide = try lane(width: "40")
|
||
#expect(LaneLayoutMath.displayUnits(of: wide) == 40)
|
||
#expect(LaneLayoutMath.totalUnits(of: [wide]) == 40)
|
||
}
|
||
|
||
@Test("The trash's one fixed unit joins the total only while it is shown")
|
||
func trashUnitJoinsTheDivision() throws {
|
||
// 03-board-ui.md § Trash: the quasi-lane "spans a fixed one width unit … consumed only
|
||
// while shown", and Show/Hide Trash is therefore a re-divide trigger — the window is never
|
||
// touched, the same width simply divides across one more unit.
|
||
let loaded = try lanes(widths: ["2", nil, "3"])
|
||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 6)
|
||
#expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 7)
|
||
|
||
// Shown on a zero-lane board it is the whole division, not a second unit alongside the
|
||
// empty board's floor of one.
|
||
#expect(LaneLayoutMath.totalUnits(of: [], trashUnits: 1) == 1)
|
||
}
|
||
}
|
||
|
||
// MARK: - Hit testing
|
||
|
||
/// `laneIndex(atX:…)` — the strip's half of drag-to-restore (03-board-ui.md § Trash). Same lane
|
||
/// geometry as the layout above: standard 100, gap 12, so with lanes of 1× and 2× units the slots
|
||
/// run [12, 112), [124, 336) and everything past 348 is the trash's side of the strip.
|
||
@Suite("LaneLayoutMath ▸ hit testing")
|
||
struct LaneHitTestingTests {
|
||
|
||
private let units = [1, 2, 1]
|
||
private func hit(_ x: CGFloat) -> Int? {
|
||
LaneLayoutMath.laneIndex(atX: x, unitCounts: units, standard: 100, gap: 12)
|
||
}
|
||
|
||
@Test("A point inside a lane's slot names that lane, width counted in units")
|
||
func insideALaneSlot() {
|
||
#expect(hit(12) == 0)
|
||
#expect(hit(111.9) == 0)
|
||
#expect(hit(124) == 1)
|
||
// The 2× lane swallows the interior gap it spans, so its slot runs 212pt, not 200.
|
||
#expect(hit(335.9) == 1)
|
||
#expect(hit(348) == 2)
|
||
#expect(hit(447.9) == 2)
|
||
}
|
||
|
||
@Test("The margins, the gaps, and everything past the last lane name nothing")
|
||
func gapsAndMarginsAreNotLanes() {
|
||
// The outer margin, before the first lane.
|
||
#expect(hit(0) == nil)
|
||
#expect(hit(11.9) == nil)
|
||
// The inter-lane gaps.
|
||
#expect(hit(112) == nil)
|
||
#expect(hit(123.9) == nil)
|
||
#expect(hit(336) == nil)
|
||
// Past the last lane — which is exactly where the trash quasi-lane sits, so a row dropped
|
||
// back into the trash writes nothing.
|
||
#expect(hit(448) == nil)
|
||
#expect(hit(10_000) == nil)
|
||
// A negative x (the pointer dragged off the leading edge) is not a lane either.
|
||
#expect(hit(-5) == nil)
|
||
}
|
||
|
||
@Test("An empty strip has no lane under any point")
|
||
func emptyStrip() {
|
||
#expect(LaneLayoutMath.laneIndex(atX: 50, unitCounts: [], standard: 100, gap: 12) == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - The snap
|
||
|
||
@Suite("LaneLayoutMath ▸ the drag's snap")
|
||
struct LaneSnapTests {
|
||
|
||
// MARK: Tick up — fires just past the far side of the gap, not inside it
|
||
|
||
@Test("Ticks up only past the far side of the trailing gap")
|
||
func ticksUpOnlyPastTheFarSideOfTheGap() {
|
||
// Slot 1 is 100; its trailing gap runs to 112 (100 + 12). The shadow must not lead the live
|
||
// edge while the edge is still IN the gap.
|
||
#expect(snapped(111.9, current: 1) == 1) // still inside the gap holds
|
||
#expect(snapped(112, current: 1) == 1) // exactly at the far edge holds (strict >)
|
||
#expect(snapped(112.1, current: 1) == 2) // clearing the gap ticks up immediately
|
||
}
|
||
|
||
@Test("Ticks up by exactly one per call")
|
||
func ticksUpByExactlyOne() {
|
||
// A live width well into 3×'s slot still steps only one unit per call (the continuous drag
|
||
// calls this on every event, and the session iterates it to a fixed point).
|
||
#expect(snapped(1000, current: 1) == 2)
|
||
#expect(snapped(1000, current: 2) == 3)
|
||
}
|
||
|
||
// MARK: Tick down — fires only 10pt back inside the gap just crossed
|
||
|
||
@Test("Ticks down only below the 10pt re-entry point")
|
||
func ticksDownOnlyBelowTheReentryPoint() {
|
||
// Coming back from 2×, the boundary is slot(1) + gap = 112; tick-down requires retreating a
|
||
// further 10pt to 102.
|
||
#expect(snapped(102.1, current: 2) == 2) // just above the re-entry point holds
|
||
#expect(snapped(102, current: 2) == 2) // exactly at the re-entry point holds (strict <)
|
||
#expect(snapped(101.9, current: 2) == 1) // clearing the re-entry point ticks down
|
||
}
|
||
|
||
@Test("Re-entering the gap is not enough to tick down")
|
||
func ticksDownDoesNotFireWhileStillInTheGap() {
|
||
// Immediately after re-entering the gap (e.g. 110), the edge has NOT yet retreated the full
|
||
// 10pt, so the shadow must still hold at 2× — this is the asymmetry: growing was instant,
|
||
// shrinking is not.
|
||
#expect(snapped(110, current: 2) == 2)
|
||
#expect(snapped(103, current: 2) == 2)
|
||
}
|
||
|
||
// MARK: Hold-band stability from both directions
|
||
|
||
@Test("The hold band is stable from both directions")
|
||
func holdBandIsStable() {
|
||
// The hold band for shadow 2× is (102, 224] — everything strictly between the 1↔2 re-entry
|
||
// point and the 2↔3 tick-up threshold holds at 2× with no oscillation.
|
||
for width in stride(from: CGFloat(103), through: 223, by: 20) {
|
||
#expect(snapped(width, current: 2) == 2, "2× holds at \(width)")
|
||
}
|
||
}
|
||
|
||
@Test("A value that just ticked up does not immediately tick back down")
|
||
func noImmediateReversalAfterTickingUp() {
|
||
// Crossing 112.1 ticks 1× → 2×. Re-evaluating at that same live width with the NEW unit
|
||
// count must hold, not bounce back — 112.1 is comfortably above the 2↔1 re-entry point.
|
||
let justTicked = snapped(112.1, current: 1)
|
||
#expect(justTicked == 2)
|
||
#expect(snapped(112.1, current: justTicked) == 2)
|
||
}
|
||
|
||
@Test("A value that just ticked down does not immediately tick back up")
|
||
func noImmediateReversalAfterTickingDown() {
|
||
let justTicked = snapped(101.9, current: 2)
|
||
#expect(justTicked == 1)
|
||
#expect(snapped(101.9, current: justTicked) == 1)
|
||
}
|
||
|
||
// MARK: Range clamping
|
||
|
||
@Test("The snap never steps past the allowed range")
|
||
func neverTicksPastTheAllowedRange() {
|
||
#expect(snapped(5000, current: 3) == 3, "the range's ceiling holds")
|
||
#expect(snapped(0, current: 1) == 1, "one unit is the floor")
|
||
#expect(snapped(-500, current: 1) == 1)
|
||
}
|
||
|
||
@Test("The range's ceiling is the only ceiling, and it is not a width cap")
|
||
func snapRespectsTheAllowedCeiling() {
|
||
// With the range capped at 2×, no live width ticks to 3×.
|
||
let capsAtTwo = 1...2
|
||
#expect(snapped(1000, current: 2, range: capsAtTwo) == 2)
|
||
#expect(snapped(5000, current: 2, range: capsAtTwo) == 2)
|
||
// Below the cap it still ticks normally.
|
||
#expect(snapped(200, current: 1, range: capsAtTwo) == 2)
|
||
// A roomier ceiling keeps ticking well past the pathfinder's old 3× — Lanework's width has
|
||
// no cap of its own (03-board-ui.md § Lane).
|
||
#expect(snapped(5000, current: 3, range: 1...9) == 4)
|
||
#expect(snapped(5000, current: 8, range: 1...9) == 9)
|
||
}
|
||
|
||
// MARK: maxUnits — the boundary, not the ceiling
|
||
|
||
@Test("maxUnits turns window headroom into whole growable units")
|
||
func maxUnitsFromHeadroom() {
|
||
// Two whole steps of headroom (2 × 112 = 224) → grow up to +2.
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3)
|
||
// Just over one step → +1.
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 120, step: step) == 2)
|
||
// Less than a step → no window growth left, so the drag re-divides from its very first
|
||
// tick; shrinking stays allowed either way.
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 50, step: step) == 1)
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 0, step: step) == 2)
|
||
// Never below currentUnits even with a negative headroom (a window already past the visible
|
||
// frame) — shrinking is always allowed.
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: -300, step: step) == 2)
|
||
// No width ceiling to clamp against: a huge screen means a huge fit. The pathfinder capped
|
||
// this at 3.
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 10_000, step: step) == 90)
|
||
// A degenerate step is not divided by.
|
||
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 500, step: 0) == 2)
|
||
}
|
||
|
||
// MARK: Rubber-band resistance
|
||
|
||
@Test("Inside the bounds the live width passes through untouched")
|
||
func widthPassesThroughInsideBounds() {
|
||
#expect(LaneLayoutMath.resistedWidth(proposed: 200, minSlot: slot(1),
|
||
maxSlot: slot(3), resistance: 0.25) == 200)
|
||
#expect(LaneLayoutMath.resistedWidth(proposed: slot(1), minSlot: slot(1),
|
||
maxSlot: slot(3), resistance: 0.25) == slot(1))
|
||
#expect(LaneLayoutMath.resistedWidth(proposed: slot(3), minSlot: slot(1),
|
||
maxSlot: slot(3), resistance: 0.25) == slot(3))
|
||
}
|
||
|
||
@Test("Past either bound the edge gives only a quarter of the overshoot")
|
||
func widthResistsPastEitherBound() {
|
||
// 20 below the 100 floor → 100 − 20·0.25 = 95.
|
||
#expect(LaneLayoutMath.resistedWidth(proposed: 80, minSlot: slot(1),
|
||
maxSlot: slot(3), resistance: 0.25) == 95)
|
||
// 76 above the 324 ceiling → 324 + 76·0.25 = 343.
|
||
#expect(LaneLayoutMath.resistedWidth(proposed: 400, minSlot: slot(1),
|
||
maxSlot: slot(3), resistance: 0.25) == 343)
|
||
}
|
||
}
|
||
|
||
// MARK: - The drag past the screen
|
||
|
||
/// The right-edge drag's **second regime** (03-board-ui.md § Lane, settled 2026-08-08): at the
|
||
/// screen's visible frame the window stops growing, and each further tick re-divides the now-pinned
|
||
/// strip across one more unit instead — the stepper's mechanism, driven by the drag, with the
|
||
/// siblings compressing. Before this the tick simply clamped at the fit, so a lane on a maximised
|
||
/// window refused to widen at all.
|
||
///
|
||
/// Fixture: the file's standard of 100 and gap of 12 on a **four-unit strip** — the dragged 1× lane,
|
||
/// two more 1× lanes and the shown trash's fixed unit — with two whole steps of screen headroom, so
|
||
/// the fit is 3×. The strip is 100·4 + 12·5 = 460 at drag start and 460 + 2·112 = 684 once the window
|
||
/// is flush against the screen, which is the width every tick past 3× re-divides.
|
||
@Suite("LaneLayoutMath ▸ the drag past the screen")
|
||
struct LaneRedivideTests {
|
||
|
||
private let startUnits = 1
|
||
private let startTotal = 4
|
||
private let fit = 3
|
||
private let pinned: CGFloat = 684
|
||
|
||
private func standardFor(_ units: Int) -> CGFloat {
|
||
LaneLayoutMath.resizeStandard(
|
||
forUnits: units, startUnits: startUnits, startStandard: standard,
|
||
startTotalUnits: startTotal, fittingUnits: fit, gap: gap)
|
||
}
|
||
|
||
private func slotFor(_ units: Int) -> CGFloat {
|
||
LaneLayoutMath.slotWidth(units: units, standard: standardFor(units), gap: gap)
|
||
}
|
||
|
||
private var ceiling: Int {
|
||
LaneLayoutMath.resizeMaxUnits(
|
||
startUnits: startUnits, startStandard: standard,
|
||
startTotalUnits: startTotal, fittingUnits: fit, gap: gap)
|
||
}
|
||
|
||
private func snapped(_ liveWidth: CGFloat, current: Int) -> Int {
|
||
LaneLayoutMath.snappedUnits(liveWidth: liveWidth, currentUnits: current,
|
||
slotFor: slotFor, gap: gap,
|
||
allowedRange: 1...ceiling, reentry: reentry)
|
||
}
|
||
|
||
// MARK: The pinned strip
|
||
|
||
@Test("The pinned strip is the drag-start strip plus one step per unit of screen headroom")
|
||
func pinnedStripWidthIsDerivedNotMeasured() {
|
||
#expect(LaneLayoutMath.pinnedStripWidth(
|
||
startUnits: startUnits, startStandard: standard,
|
||
startTotalUnits: startTotal, fittingUnits: fit, gap: gap) == pinned)
|
||
// No headroom at all: the strip is exactly what the frozen standard and the unit total
|
||
// imply, and the re-divide starts from the very first tick.
|
||
#expect(LaneLayoutMath.pinnedStripWidth(
|
||
startUnits: startUnits, startStandard: standard,
|
||
startTotalUnits: startTotal, fittingUnits: startUnits, gap: gap) == 460)
|
||
}
|
||
|
||
// MARK: Continuity at the boundary
|
||
|
||
@Test("The two regimes meet with no pixel jump")
|
||
func theBoundaryCostsNothing() {
|
||
// Everything up to the fit is the frozen standard: the window took the step, so the
|
||
// division never moved.
|
||
#expect(standardFor(1) == standard)
|
||
#expect(standardFor(2) == standard)
|
||
#expect(standardFor(fit) == standard, "the re-divided standard AT the fit is the frozen one")
|
||
// Past it the same width divides across one more unit, so it can only shrink.
|
||
#expect(standardFor(fit + 1) < standard)
|
||
#expect(abs(standardFor(fit + 1) - 84) < 0.0001) // (684 − 12·8) / 7
|
||
#expect(standardFor(fit + 2) < standardFor(fit + 1))
|
||
}
|
||
|
||
@Test("Past the fit the strip still fills exactly — the width is pinned, the division is not")
|
||
func exactFillSurvivesTheRedivide() {
|
||
for units in (fit + 1)...12 {
|
||
let redivided = standardFor(units)
|
||
// Four boxes stand in the strip — the dragged lane, two 1× lanes and the trash — so
|
||
// there are five gaps: the three between them and the two outer margins.
|
||
let filled = slotFor(units) + 3 * redivided + 5 * gap
|
||
#expect(abs(filled - pinned) < 0.0001, "\(units)× must still fill the pinned strip exactly")
|
||
}
|
||
}
|
||
|
||
// MARK: The snap, measured slot by slot
|
||
|
||
@Test("A tick past the fit fires on the re-divided slot, and does not double-tick")
|
||
func theRedivideTicksOnceAndSettles() {
|
||
// 3× is the last slot the window pays for: 324 wide, its trailing gap ending at 336.
|
||
let threshold = slotFor(fit) + gap
|
||
#expect(threshold == 336)
|
||
#expect(snapped(threshold, current: fit) == fit, "exactly at the far edge holds (strict >)")
|
||
|
||
let ticked = snapped(threshold + 0.1, current: fit)
|
||
#expect(ticked == fit + 1, "the old clamp at the screen fit is gone")
|
||
|
||
// 4× is measured against the SMALLER standard the re-divide produced, and its threshold
|
||
// still sits beyond the width that fired the tick — so the session's iteration settles in
|
||
// one step rather than running away up the strip.
|
||
#expect(slotFor(fit + 1) + gap > threshold + 0.1)
|
||
#expect(snapped(threshold + 0.1, current: ticked) == ticked, "no double tick")
|
||
// And it does not immediately reverse either: the tick-down threshold is 10pt back inside
|
||
// the gap it just cleared.
|
||
#expect(snapped(threshold + 0.1, current: ticked) != fit)
|
||
}
|
||
|
||
@Test("Ticking back down retreats through the same re-divided slots")
|
||
func theRedivideTicksBackDown() {
|
||
// Coming back from 4×, the boundary is slot(3) + gap = 336 and the re-entry point 10pt
|
||
// inside it, at 326 — the same asymmetry as within the fit.
|
||
#expect(snapped(326, current: fit + 1) == fit + 1)
|
||
#expect(snapped(325.9, current: fit + 1) == fit)
|
||
#expect(snapped(330, current: fit + 1) == fit + 1, "re-entering the gap is not enough")
|
||
}
|
||
|
||
@Test("The ceiling is the strip's capacity, and units run well past the screen fit")
|
||
func theCeilingIsTheStripsCapacity() {
|
||
// 684 wide with 12pt gaps divides into at most 51 whole units before `standardWidth`'s 1pt
|
||
// floor would break the exact fill — floor((684 − 12) / 13) — and the dragged lane reads
|
||
// that back through the 47 units it added.
|
||
#expect(ceiling == 48)
|
||
#expect(ceiling >= fit)
|
||
#expect(standardFor(ceiling) >= 1)
|
||
#expect(abs(slotFor(ceiling) + 3 * standardFor(ceiling) + 5 * gap - pinned) < 0.0001)
|
||
// One unit further the 1pt floor engages, the division stops being a division, and the
|
||
// strip would overflow — which is precisely why the ceiling sits where it does.
|
||
#expect(standardFor(ceiling + 1) == 1)
|
||
#expect(slotFor(ceiling + 1) + 3 + 5 * gap > pinned)
|
||
// The snap walks all the way there and stops.
|
||
#expect(snapped(5000, current: fit) == fit + 1)
|
||
#expect(snapped(5000, current: ceiling) == ceiling)
|
||
}
|
||
|
||
@Test("The ceiling never falls below the screen fit, and a degenerate strip falls back to it")
|
||
func theCeilingFallsBackToTheFit() {
|
||
// Shrinking is always allowed, so the fit is the floor of the ceiling however odd the
|
||
// inputs are — a non-finite standard and a gap that would make the capacity formula
|
||
// meaningless both answer the fit rather than inventing a bound.
|
||
let degenerate: [(CGFloat, CGFloat)] = [(.nan, gap), (.infinity, gap), (standard, -1), (standard, -50)]
|
||
for (brokenStandard, brokenGap) in degenerate {
|
||
#expect(LaneLayoutMath.resizeMaxUnits(
|
||
startUnits: startUnits, startStandard: brokenStandard,
|
||
startTotalUnits: startTotal, fittingUnits: fit, gap: brokenGap) == fit)
|
||
}
|
||
// A start already past the fit (a window hanging off the screen) still cannot be clamped
|
||
// below where it stands.
|
||
#expect(LaneLayoutMath.resizeMaxUnits(
|
||
startUnits: 6, startStandard: .nan,
|
||
startTotalUnits: startTotal, fittingUnits: fit, gap: gap) == 6)
|
||
}
|
||
|
||
// MARK: The window's share of a tick
|
||
|
||
@Test("The window moves only for the part of a step that fits on screen")
|
||
func theWindowTakesOnlyItsShare() {
|
||
// Wholly inside the fit: every unit is the window's.
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 1, to: 3, fittingUnits: fit, step: step) == 2 * step)
|
||
// A flick across the boundary: only the first unit was ever the window's to give.
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 2, to: 5, fittingUnits: fit, step: step) == step)
|
||
// Wholly above it: the window is pinned and the re-divide does the whole of the work.
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 7, fittingUnits: fit, step: step) == 0)
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: fit, to: fit + 1, fittingUnits: fit, step: step) == 0)
|
||
// Shrinking mirrors it exactly — the step handed back is the one that was taken.
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 5, to: 2, fittingUnits: fit, step: step) == -step)
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 7, to: 4, fittingUnits: fit, step: step) == 0)
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 3, to: 1, fittingUnits: fit, step: step) == -2 * step)
|
||
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 4, fittingUnits: fit, step: step) == 0)
|
||
}
|
||
}
|
||
|
||
// MARK: - Collapsed lanes
|
||
|
||
/// **The fold's arithmetic** (03-board-ui.md § Lane ▸ Collapsed lanes, settled 2026-08-08): a collapsed
|
||
/// lane takes a fixed slim strip and is *not* part of the width re-division — its width comes off the
|
||
/// top and what is left divides among the expanded lanes alone.
|
||
///
|
||
/// The exact-fill identity is the property worth pinning rather than any one number, because it is what
|
||
/// "every lane is always on screen" means arithmetically once two kinds of lane share the strip:
|
||
/// `C·collapsedWidth + T·standard + (T + C + 1)·gap` is the whole strip, always.
|
||
@Suite("LaneLayoutMath ▸ collapsed lanes")
|
||
struct LaneCollapseLayoutTests {
|
||
|
||
/// A slim strip in this suite. Deliberately not `BoardMetrics`' figure: the math takes it as a
|
||
/// parameter, and a test that read the metric would pin the metric rather than the arithmetic.
|
||
private let strip: CGFloat = 44
|
||
|
||
@Test("The reading is lenient: only a real `true` folds a lane")
|
||
func onlyTrueFolds() throws {
|
||
#expect(LaneLayoutMath.isCollapsed(try lane(width: nil)) == false)
|
||
let folded = try lanes(widths: [nil], collapsed: ["true"])
|
||
#expect(LaneLayoutMath.isCollapsed(try #require(folded.first)))
|
||
|
||
// An explicit `false`, a quoted word, and a value with no boolean reading at all — three
|
||
// shapes, two readings, and the malformed one is expanded because the default is the absent
|
||
// key's.
|
||
for (raw, expected) in [("false", false), ("\"yes\"", true), ("maybe", false), ("3", false)] {
|
||
let loaded = try lanes(widths: [nil], collapsed: [raw])
|
||
#expect(LaneLayoutMath.isCollapsed(try #require(loaded.first)) == expected,
|
||
"collapsed: \(raw) should read as \(expected ? "folded" : "expanded")")
|
||
}
|
||
}
|
||
|
||
@Test("Collapsed lanes are outside the unit total, so folding one is a re-divide trigger")
|
||
func collapsedLanesLeaveTheUnitTotal() throws {
|
||
let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil])
|
||
// The folded lane keeps its `width` — that is how expanding restores the lane the user had —
|
||
// and still answers `displayUnits`; it simply contributes none of them to the division.
|
||
#expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 3, 1])
|
||
#expect(LaneLayoutMath.collapsedCount(of: loaded) == 1)
|
||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 3)
|
||
#expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 4)
|
||
}
|
||
|
||
@Test("The strip still fills exactly with both kinds of lane in it")
|
||
func mixedStripsFillExactly() throws {
|
||
let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil])
|
||
let total = LaneLayoutMath.totalUnits(of: loaded)
|
||
let folded = LaneLayoutMath.collapsedCount(of: loaded)
|
||
let standard = LaneLayoutMath.standardWidth(
|
||
stripWidth: 1000, totalUnits: total, gap: gap,
|
||
collapsedCount: folded, collapsedWidth: strip)
|
||
|
||
// 1000 = 3 standards + 1 strip + 5 gaps (4 slots, so 5 gaps counting both margins).
|
||
#expect(abs(standard - (1000 - strip - gap * 5) / 3) < 0.0001)
|
||
let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip)
|
||
#expect(abs(widths.reduce(0, +) + gap * CGFloat(widths.count + 1) - 1000) < 0.0001)
|
||
// The expanded lanes are their slots; the folded one is the constant, whatever its `width`.
|
||
#expect(abs(widths[0] - LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) < 0.0001)
|
||
#expect(widths[1] == strip)
|
||
#expect(abs(widths[2] - standard) < 0.0001)
|
||
}
|
||
|
||
@Test("Folding a lane widens its siblings without touching the window")
|
||
func foldingRedividesTheRemainder() throws {
|
||
let expanded = try lanes(widths: [nil, nil, nil])
|
||
let folded = try lanes(widths: [nil, nil, nil], collapsed: [nil, nil, "true"])
|
||
|
||
let before = LaneLayoutMath.standardWidth(
|
||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: expanded), gap: gap,
|
||
collapsedCount: 0, collapsedWidth: strip)
|
||
let after = LaneLayoutMath.standardWidth(
|
||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: folded), gap: gap,
|
||
collapsedCount: LaneLayoutMath.collapsedCount(of: folded), collapsedWidth: strip)
|
||
#expect(after > before, "the two survivors grow into what the third gave up")
|
||
}
|
||
|
||
@Test("A board with every lane folded divides nothing and still answers a positive width")
|
||
func allCollapsedIsTheDegenerateEdge() throws {
|
||
let loaded = try lanes(widths: ["2", "3"], collapsed: ["true", "true"])
|
||
#expect(LaneLayoutMath.collapsedCount(of: loaded) == 2)
|
||
// No expanded unit exists, so the total is the divisor guard rather than a description of
|
||
// anything on screen — and nothing draws with the standard it produces.
|
||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 1)
|
||
let standard = LaneLayoutMath.standardWidth(
|
||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded), gap: gap,
|
||
collapsedCount: 2, collapsedWidth: strip)
|
||
#expect(standard > 0)
|
||
let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip)
|
||
#expect(widths == [strip, strip])
|
||
|
||
// A shown trash column *is* a real unit in that total, and correctly takes the remainder.
|
||
let withTrash = LaneLayoutMath.standardWidth(
|
||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1), gap: gap,
|
||
collapsedCount: 2, collapsedWidth: strip)
|
||
#expect(abs(withTrash - (1000 - 2 * strip - gap * 4)) < 0.0001)
|
||
|
||
// The pathological strip stays positive rather than negative, `standardWidth`'s 1pt floor.
|
||
#expect(LaneLayoutMath.standardWidth(
|
||
stripWidth: 10, totalUnits: 1, gap: gap, collapsedCount: 4, collapsedWidth: strip) == 1)
|
||
}
|
||
|
||
@Test("A strip with no folded lane reads exactly as it did before the key existed")
|
||
func theDefaultsAreInert() {
|
||
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap)
|
||
== LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap,
|
||
collapsedCount: 0, collapsedWidth: strip))
|
||
}
|
||
|
||
@Test("Hit testing walks the drawn widths, so a folded lane's zone is where it is drawn")
|
||
func hitTestingFollowsTheDrawnWidths() throws {
|
||
let loaded = try lanes(widths: [nil, nil, nil], collapsed: [nil, "true", nil])
|
||
let localStandard: CGFloat = 100
|
||
let widths = LaneLayoutMath.drawnWidths(
|
||
of: loaded, standard: localStandard, gap: gap, collapsedWidth: strip)
|
||
#expect(widths == [100, strip, 100])
|
||
|
||
// Lane 0 spans [12, 112), the strip [124, 168), lane 2 [180, 280) — gaps answer nil.
|
||
#expect(LaneLayoutMath.laneIndex(atX: 50, widths: widths, gap: gap) == 0)
|
||
#expect(LaneLayoutMath.laneIndex(atX: 118, widths: widths, gap: gap) == nil)
|
||
#expect(LaneLayoutMath.laneIndex(atX: 130, widths: widths, gap: gap) == 1)
|
||
#expect(LaneLayoutMath.laneIndex(atX: 167, widths: widths, gap: gap) == 1)
|
||
#expect(LaneLayoutMath.laneIndex(atX: 200, widths: widths, gap: gap) == 2)
|
||
#expect(LaneLayoutMath.laneIndex(atX: 400, widths: widths, gap: gap) == nil)
|
||
|
||
// The unit-count entry point is the same walk, so a board with no folded lane cannot answer
|
||
// differently from one with.
|
||
#expect(LaneLayoutMath.laneIndex(atX: 130, unitCounts: [1, 1, 1], standard: localStandard, gap: gap)
|
||
== LaneLayoutMath.laneIndex(atX: 130, widths: [100, 100, 100], gap: gap))
|
||
}
|
||
|
||
@Test("A dragged run's span is its drawn footprint, folded members included")
|
||
func draggedRunsMeasureWhatTheyWillDraw() {
|
||
let localStandard: CGFloat = 100
|
||
// Two dragged lanes, the second folded: 100 + gap + 44 rather than 100 + gap + 100.
|
||
let widths = LaneLayoutMath.drawnWidths(
|
||
units: [1, 3], collapsed: [false, true],
|
||
standard: localStandard, gap: gap, collapsedWidth: strip)
|
||
#expect(widths == [100, strip])
|
||
#expect(DropSlotMath.laneRunSpan(widths: widths, gap: gap) == 100 + gap + strip)
|
||
|
||
// A shorter `collapsed` list reads as expanded past its end — what a caller with no fold to
|
||
// report passes (the trashed-lane restore).
|
||
#expect(LaneLayoutMath.drawnWidths(
|
||
units: [1, 2], collapsed: [],
|
||
standard: localStandard, gap: gap, collapsedWidth: strip)
|
||
== [100, LaneLayoutMath.slotWidth(units: 2, standard: localStandard, gap: gap)])
|
||
}
|
||
}
|