The lane title bar becomes real: leading SF Symbol (hand-written names render leniently, unknown ones fall back to the level default), title or secondary untitled placeholder, a quiet count badge that counts exactly the cards the body renders (so the m5 search filter is followed by construction), and a new-card button. The whole bar is the reorder drag surface — no grip — with click-vs-movement splitting select from drag; a pure proposal function maps the drag to an insertion index and release commits through the Writer's same-parent degenerate reorder, compacting and retrying when midpoint precision runs out. Clicking never edits: inline rename is Return on the sole selected card or Board > Rename for either kind, a third transient editor beside the placeholder that tracks its target by UUID, commits on focus loss, discards silently when the target vanishes, and removes the title key on an empty commit. The new-card placeholder renders at last — the settled Cmd-N target rule (pure, tested) files it after the anchor card, at a selected lane's bottom, or into the last-active lane; Return commits and re-selects the lane, Cmd-Return also opens the card window, and a failed create discards the overlay. New Card / New Lane / Rename land in the menus with focused-editor and read-only validation; rename gets its own WriteOperation case in the banner vocabulary. 59 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
198 lines
7.6 KiB
Swift
198 lines
7.6 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: - 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])
|
||
}
|
||
}
|