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
376 lines
18 KiB
Swift
376 lines
18 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 *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)
|
||
}
|
||
}
|