Files
lanework/KanbanTests/RanksTests.swift
T
rzen 21a5a6dbfd Build the drop-slot model and the drop commits — drag & drop, first half
The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):

- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
  pure masonry placement (MasonryLayout now lays out through the same
  MasonryPlacement the drag reads, so geometry cannot drift), span-capped
  triggers sized to the dragged run's future footprint, hysteresis holds with
  the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
  a board (insertion ranks touch only the dragged cards; renumber fallback);
  receiveCards/receiveLanes/receiveRestoredCards on the destination store for
  cross-board copy and ⌘-move with the import-boundary remint, lane copies
  stripping tombstoned cards while moves carry them; restoreByDrag is now
  positional, writing order only when the drop names a new one.

Gestures, sessions, previews, and delegates are the second half.

773 unit tests (87 new since the keyboard grammar).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 20:10:24 -04:00

245 lines
10 KiB
Swift
Raw 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 Testing
@testable import Kanban
struct RanksTests {
// MARK: - Append
@Test func appendOnEmptyLaneReturnsBoardConvention() {
#expect(Ranks.append(toVisible: [Double]()) == 1024)
}
@Test func appendReturnsMaxPlusGap() {
#expect(Ranks.append(toVisible: [1024, 2048, 512]) == 3072)
}
// MARK: - Insert at head
@Test func insertAtHeadOnEmptyLaneReturnsBoardConvention() {
#expect(Ranks.insertAtHead(ofVisible: [Double]()) == 1024)
}
@Test func insertAtHeadReturnsMinMinusGap() {
#expect(Ranks.insertAtHead(ofVisible: [1024, 2048, 512]) == -512)
}
// MARK: - Midpoint
@Test func midpointBetweenDistinctValuesIsStrictlyBetween() throws {
let mid = try #require(Ranks.midpoint(between: 1024, and: 2048))
#expect(mid == 1536)
#expect(mid > 1024 && mid < 2048)
}
@Test func midpointIsOrderIndependent() {
let forward = Ranks.midpoint(between: 1024, and: 2048)
let reversed = Ranks.midpoint(between: 2048, and: 1024)
#expect(forward == reversed)
}
@Test func midpointBetweenEqualValuesReturnsNil() {
#expect(Ranks.midpoint(between: 42, and: 42) == nil)
}
@Test func midpointNeverEscapesTheOpenInterval() {
// Values close enough that a naive (a+b)/2 could round to one of the
// endpoints; the result (if any) must stay strictly inside.
let a = 1.0
let b = 1.0.nextUp.nextUp.nextUp
if let mid = Ranks.midpoint(between: a, and: b) {
#expect(mid > a && mid < b)
}
}
// MARK: - Renumbered
@Test func renumberedProducesWholeMultiplesOf1024() {
#expect(Ranks.renumbered(count: 4) == [1024, 2048, 3072, 4096])
}
@Test func renumberedWithZeroCountIsEmpty() {
#expect(Ranks.renumbered(count: 0) == [])
}
// MARK: - Display order / tie-break
@Test func sortedForDisplayOrdersAscendingByRank() {
let items: [(order: Double, name: String)] = [
(order: 3072, name: "c-card"),
(order: 1024, name: "a-card"),
(order: 2048, name: "b-card"),
]
let sorted = Ranks.sortedForDisplay(items, order: { $0.order }, name: { $0.name })
#expect(sorted.map { $0.name } == ["a-card", "b-card", "c-card"])
}
@Test func sortedForDisplayBreaksTiesByFolderName() {
let items: [(order: Double, name: String)] = [
(order: 1024, name: "zzz-uuid"),
(order: 1024, name: "aaa-uuid"),
(order: 1024, name: "mmm-uuid"),
]
let sorted = Ranks.sortedForDisplay(items, order: { $0.order }, name: { $0.name })
#expect(sorted.map { $0.name } == ["aaa-uuid", "mmm-uuid", "zzz-uuid"])
}
@Test func isOrderedForDisplayMatchesSortedForDisplay() {
let a: (order: Double, name: String) = (order: 1024, name: "aaa")
let b: (order: Double, name: String) = (order: 1024, name: "bbb")
#expect(Ranks.isOrderedForDisplay(a, before: b, order: { $0.order }, name: { $0.name }))
#expect(!Ranks.isOrderedForDisplay(b, before: a, order: { $0.order }, name: { $0.name }))
}
// MARK: - Tombstone exclusion
@Test func appendIgnoresTombstonedSiblings() {
let items: [(order: Double, isDeleted: Bool)] = [
(order: 1024, isDeleted: false),
(order: 9999, isDeleted: true),
(order: 2048, isDeleted: false),
]
#expect(Ranks.append(toVisible: items) == 3072)
}
@Test func insertAtHeadIgnoresTombstonedSiblings() {
let items: [(order: Double, isDeleted: Bool)] = [
(order: 1024, isDeleted: false),
(order: -9999, isDeleted: true),
(order: 2048, isDeleted: false),
]
#expect(Ranks.insertAtHead(ofVisible: items) == 0)
}
@Test func appendWithAllSiblingsTombstonedReturnsBoardConvention() {
let items: [(order: Double, isDeleted: Bool)] = [
(order: 1024, isDeleted: true),
(order: 2048, isDeleted: true),
]
#expect(Ranks.append(toVisible: items) == 1024)
}
@Test func insertAtHeadWithAllSiblingsTombstonedReturnsBoardConvention() {
let items: [(order: Double, isDeleted: Bool)] = [
(order: 1024, isDeleted: true),
(order: 2048, isDeleted: true),
]
#expect(Ranks.insertAtHead(ofVisible: items) == 1024)
}
// MARK: - Insertion at a display position
@Test func insertionRankDispatchesOnPosition() throws {
let orders = [1024.0, 2048.0, 3072.0]
// The three cases every insertion gesture has, behind one call.
#expect(Ranks.insertionRank(amongVisible: orders, at: 0) == 0, "head: min 1024")
#expect(Ranks.insertionRank(amongVisible: orders, at: 1) == 1536, "between: the midpoint")
#expect(Ranks.insertionRank(amongVisible: orders, at: 2) == 2560)
#expect(Ranks.insertionRank(amongVisible: orders, at: 3) == 4096, "end: max + 1024")
// The result is always strictly inside the gap it names, which is what makes the display
// order the caller asked for the one it gets.
let placed = try #require(Ranks.insertionRank(amongVisible: orders, at: 1))
#expect(placed > orders[0] && placed < orders[1])
}
@Test func insertionRankIsTotalOnEdgeInputs() {
// An empty parent's first child lands at the board convention, whatever index is asked for.
#expect(Ranks.insertionRank(amongVisible: [], at: 0) == 1024)
#expect(Ranks.insertionRank(amongVisible: [], at: 7) == 1024)
// Out-of-range indices clamp to the two ends rather than trapping: an index arrives from a
// drag's geometry, and geometry can outrun a snapshot.
#expect(Ranks.insertionRank(amongVisible: [1024], at: -3) == 0)
#expect(Ranks.insertionRank(amongVisible: [1024], at: 99) == 2048)
}
@Test func insertionRankReportsAnExhaustedGapRatherThanInventingOne() {
// `nil` is the renumber trigger, not a refusal — and it must fire for the duplicate-order
// tie as well as for adjacent Doubles, since neither admits a rank between.
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 1) == nil)
#expect(Ranks.insertionRank(amongVisible: [1024, 1024.0000000000002], at: 1) == nil)
// The ends never exhaust: append and head-insert always have room.
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 0) == 0)
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 2) == 2048)
}
// MARK: - A contiguous run's ranks (multi-drag)
@Test func insertionRanksPlaceARunAtOneSpotInOrder() throws {
let orders = [1024.0, 2048.0, 3072.0]
// Between two siblings: `count` evenly spaced points, strictly inside and ascending.
let interior = try #require(Ranks.insertionRanks(amongVisible: orders, at: 1, count: 3))
#expect(interior == [1280, 1536, 1792])
#expect(interior.first! > orders[0] && interior.last! < orders[1])
#expect(zip(interior, interior.dropFirst()).allSatisfy { $0 < $1 })
// The ends spread whole gaps, ascending, so the run lands as a block.
#expect(Ranks.insertionRanks(amongVisible: orders, at: 3, count: 2) == [4096, 5120])
#expect(Ranks.insertionRanks(amongVisible: orders, at: 0, count: 2) == [-1024, 0])
}
@Test func insertionRanksAgreeWithTheSingleRankTwinForOneItem() {
let orders = [1024.0, 2048.0, 3072.0]
for index in -1...4 {
#expect(Ranks.insertionRanks(amongVisible: orders, at: index, count: 1)
== Ranks.insertionRank(amongVisible: orders, at: index).map { [$0] },
"position \(index)")
}
}
@Test func insertionRanksAreTotalOnEdgeInputs() {
#expect(Ranks.insertionRanks(amongVisible: [], at: 0, count: 3) == [1024, 2048, 3072])
#expect(Ranks.insertionRanks(amongVisible: [1024], at: 99, count: 2) == [2048, 3072])
#expect(Ranks.insertionRanks(amongVisible: [1024], at: -3, count: 2) == [-1024, 0])
// A run of nothing is nothing, not a failure: a drag emptied by a foreign reload cancels
// itself, and the write it would have made is simply empty.
#expect(Ranks.insertionRanks(amongVisible: [1024], at: 0, count: 0) == [])
}
@Test func insertionRanksReportAnExhaustedGapRatherThanInventingOne() {
// The duplicate-order tie and adjacent Doubles are both renumber triggers, exactly as for
// the single-rank twin — and a gap that fits one rank need not fit three.
#expect(Ranks.insertionRanks(amongVisible: [1024, 1024], at: 1, count: 2) == nil)
#expect(Ranks.insertionRanks(amongVisible: [1024, 1024.0000000000002], at: 1, count: 1) == nil)
let tight = [1.0, 1.0.nextUp.nextUp]
#expect(Ranks.insertionRanks(amongVisible: tight, at: 1, count: 1) != nil)
#expect(Ranks.insertionRanks(amongVisible: tight, at: 1, count: 4) == nil)
// The ends never exhaust.
#expect(Ranks.insertionRanks(amongVisible: [1024, 1024], at: 2, count: 2) == [2048, 3072])
}
// MARK: - Precision exhaustion → renumber, deterministically
@Test func precisionExhaustionThenRenumberIsDeterministic() {
func runScenario() -> (iterations: Int, renumbered: [Double]) {
let lower = 1024.0
var upper = 2048.0
var iterations = 0
let iterationCap = 4000
while iterations < iterationCap, let mid = Ranks.midpoint(between: lower, and: upper) {
upper = mid
iterations += 1
}
return (iterations, Ranks.renumbered(count: 5))
}
let first = runScenario()
let second = runScenario()
// Precision must actually have been exhausted, not just hit the cap.
#expect(first.iterations > 0)
#expect(first.iterations < 4000)
// Same scenario, run twice, must produce bit-identical results.
#expect(first.iterations == second.iterations)
#expect(first.renumbered == second.renumbered)
// Renumbering yields clean whole multiples of 1024.
#expect(first.renumbered == [1024, 2048, 3072, 4096, 5120])
}
}