Files
lanework/KanbanTests/LaneLayoutMathTests.swift
rzen 2090d742b9 Build the trash quasi-lane
Deletion becomes a two-stage, Finder-style story. File > Delete and
plain Backspace tombstone the live selection; View > Show Trash (no
chord — Shift-Cmd-T stays with the system's tab bar) reveals the
quasi-lane: trailing, one fixed width unit consumed only while shown,
hatched dimmed header, count badge, no new-card button, exempt from
resize and reorder alike. Its contents are a pure view over the
snapshot — the deterministic sort (deleted newest first, folder-name
ties, unparseable stamps oldest) interleaves card rows with a
tombstoned lane's single entry, whose count names what Put Back
returns; the ancestor walk is absolute, so an own-flag card beneath a
tombstoned lane has no row and recovery is deliberately two steps.
Put Back twins Delete on Cmd-Backspace with validation enabling
exactly one; restore fidelity is byte-perfect because nothing ever
moved. Delete Immediately confirms exactly where loss is real (every
board is mode-none today; the predicate names the git carve-out for
m7), Empty Trash always confirms with the true whole-board count,
and dragging a tombstoned card onto a live lane restores it there —
positional drops and cross-board locality arrive with m5's machinery.
The banner's delete phrasing drops "move to the trash" per the naming
constraint: board deletion says Delete, "Move to Trash" stays
reserved for the system Trash. 47 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 15:38:34 -04:00

376 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 *screen fit*: in
/// Lanework `allowedRange`'s ceiling is only ever how far the window can grow (`maxUnits`), because
/// the width field itself has no cap.
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.
private func lanes(widths: [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"
}
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 on-screen fit is the ceiling")
#expect(snapped(0, current: 1) == 1, "one unit is the floor")
#expect(snapped(-500, current: 1) == 1)
}
@Test("The ceiling is the screen fit, and it is the only ceiling")
func snapRespectsTheOnScreenFit() {
// With the fit capping the range at 2×, no live width ticks to 3×.
let fitsTwo = 1...2
#expect(snapped(1000, current: 2, range: fitsTwo) == 2)
#expect(snapped(5000, current: 2, range: fitsTwo) == 2)
// Below the cap it still ticks normally.
#expect(snapped(200, current: 1, range: fitsTwo) == 2)
// A roomier screen keeps ticking well past the pathfinder's old 3× ceiling — 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
@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 growth room, but shrinking stays allowed.
#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)
}
}