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
245 lines
10 KiB
Swift
245 lines
10 KiB
Swift
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])
|
||
}
|
||
}
|