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
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+88
View File
@@ -1571,6 +1571,94 @@ struct BoardWriterCopyTests {
}
}
// MARK: - Stripping a copied lane's tombstones
/// `BoardWriter.stripTombstonedChildren` the tail of a lane copy (04-interactions.md Drag and
/// drop: "A lane copy **strips tombstoned cards**"). `copyItem` copies the tree verbatim by
/// design, so the strip is the line after it rather than a filter inside it.
struct BoardWriterStripTombstonesTests {
/// A tombstoned card, as an agent or a delete leaves it.
private static func tombstone(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\ndeleted: 2026-03-03T09:00:00Z\n---\n\(title) body.\n"
}
@Test func onlyTheTombstonedChildrenAreRemoved() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)",
Self.tombstone(order: "2048", title: "Trashed"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Also live"))
let live = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed == [ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card2)"))
// Removed, never tombstoned, and the survivors are not rewritten on the way past.
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card3)"))
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") == live)
#expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty)
}
@Test func aWholeTombstonedFolderGoesWithItsAttachments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)",
Self.tombstone(order: "1024", title: "Trashed"))
try fixture.file("A.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x89, 0x50]))
_ = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
}
@Test func nonUUIDStraysAndUnreadableChildrenAreLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
// A stray is not a level at all; a UUID-shaped folder with no `index.md` cannot be asked
// the liveness question, and the conservative direction is to keep it.
try fixture.file("A.kanban/\(Ident.lane1)/notes/scratch.txt", Data("hand-written\n".utf8))
try FileManager.default.createDirectory(at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"),
withIntermediateDirectories: true)
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed.isEmpty)
#expect(fixture.exists("A.kanban/\(Ident.lane1)/notes/scratch.txt"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.indexless)"))
}
@Test func aLaneWithNothingTombstonedIsUntouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
#expect(try BoardWriter.stripTombstonedChildren(of: lane).isEmpty)
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").sorted() == [Ident.card1, "index.md"].sorted())
}
@Test func aMissingFolderIsALoudErrorNamingTheCopy() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let error = writeFailure { _ = try BoardWriter.stripTombstonedChildren(of: fixture.url("A.kanban/\(Ident.lane1)")) }
// The user pressed nothing called "delete": a failure here must say the copy failed.
#expect(error?.operation == .copy(title: nil))
}
}
// MARK: - Delete / Restore
/// `BoardWriter.deleteItem`/`restoreItem` the tombstone half of 01-storage-format.md §
+166
View File
@@ -0,0 +1,166 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `DragAutoScrollMath` given a viewport and a pointer inside (or just outside) it, how fast, and
/// which way, should the scroll view move? Ported from the pathfinder's suite, whose numbers are
/// what was proven. The live driver is the drag session's; this is the decision it makes 60 times a
/// second (DRAG-REORDER.md § Edge autoscroll).
private let length: CGFloat = 400
private let band = DragAutoScrollMath.band
private let minSpeed = DragAutoScrollMath.minSpeed
private let maxSpeed = DragAutoScrollMath.maxSpeed
private func velocity(_ position: CGFloat, length viewport: CGFloat = length) -> CGFloat {
DragAutoScrollMath.velocity(position: position, length: viewport)
}
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
abs(value - expected) <= tolerance
}
@Suite("DragAutoScrollMath")
struct DragAutoScrollMathTests {
// MARK: The neutral middle
@Test("The middle of the viewport never scrolls")
func middleNeverScrolls() {
for position in stride(from: band, through: length - band, by: 8) {
#expect(velocity(position) == 0, "cursor \(position) is outside both bands")
}
// The band boundaries themselves are neutral a band is the region strictly inside one.
#expect(velocity(band) == 0)
#expect(velocity(length - band) == 0)
}
// MARK: Direction
@Test("The leading band scrolls toward the start and the trailing band toward the end")
func direction() {
#expect(velocity(band - 1) < 0)
#expect(velocity(0) < 0)
#expect(velocity(length - band + 1) > 0)
#expect(velocity(length) > 0)
}
// MARK: The ramp
@Test("Speed ramps with edge proximity, on both ends")
func speedRampsWithProximity() {
var previous = abs(velocity(band - 0.5))
for position in stride(from: band - 8, through: 0, by: -8) {
let speed = abs(velocity(position))
#expect(speed > previous, "cursor \(position) should beat the shallower sample")
previous = speed
}
previous = abs(velocity(length - band + 0.5))
for position in stride(from: length - band + 8, through: length, by: 8) {
let speed = abs(velocity(position))
#expect(speed > previous, "cursor \(position) should beat the shallower sample")
previous = speed
}
}
@Test("The ramp spans the floor to the ceiling, linearly")
func rampIsLinearBetweenFloorAndCeiling() {
// Just inside the band: the floor, which exists so entering a band produces visible motion
// rather than an imperceptible crawl. At the viewport edge: the ceiling. Halfway: the mean.
#expect(isClose(abs(velocity(band - 0.0001)), minSpeed, 0.01))
#expect(isClose(abs(velocity(0)), maxSpeed))
#expect(isClose(abs(velocity(band / 2)), (minSpeed + maxSpeed) / 2))
#expect(isClose(abs(velocity(length)), maxSpeed))
#expect(isClose(abs(velocity(length - band / 2)), (minSpeed + maxSpeed) / 2))
}
@Test("Beyond the viewport edge the speed saturates rather than growing")
func saturatesBeyondTheEdge() {
// A pointer over the lane header (above the scroll area) or below its bottom padding drives
// the fastest scroll, never faster.
#expect(isClose(velocity(-40), -maxSpeed))
#expect(isClose(velocity(-4000), -maxSpeed))
#expect(isClose(velocity(length + 40), maxSpeed))
}
// MARK: Degenerate viewports
@Test("A short viewport halves its bands instead of overlapping them")
func shortViewport() {
let short: CGFloat = 60
#expect(velocity(30, length: short) == 0, "the exact centre still resolves to no scrolling")
#expect(velocity(29, length: short) < 0)
#expect(velocity(31, length: short) > 0)
#expect(isClose(abs(velocity(0, length: short)), maxSpeed))
}
@Test("An empty or inverted viewport never scrolls")
func emptyViewport() {
#expect(velocity(0, length: 0) == 0)
#expect(velocity(10, length: -5) == 0)
}
// MARK: Two axes
@Test("The two axes are resolved independently")
func axesAreIndependent() {
let viewport = CGSize(width: 400, height: 400)
let bottom = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 390), viewport: viewport)
#expect(bottom.dx == 0)
#expect(bottom.dy > 0)
let corner = DragAutoScrollMath.velocity(pointer: CGPoint(x: 2, y: 2), viewport: viewport)
#expect(corner.dx < 0)
#expect(corner.dy < 0)
let centre = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 200), viewport: viewport)
#expect(centre.dx == 0)
#expect(centre.dy == 0)
}
// MARK: Engagement reach
@Test("Engagement reaches over the header but barely sideways")
func engagementReach() {
let viewport = CGSize(width: 240, height: 400)
let reach = DragAutoScrollMath.engagementRect(viewport: viewport)
#expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always")
// Above it (the lane header) and below it (the strip's padding).
#expect(reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove + 1)))
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1)))
// Sideways: only a sliver, so the neighbouring lane never engages.
#expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200)))
#expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200)))
#expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 1, y: 200)))
// The sideways reach must stay under half the distance between two lanes' scroll areas, or
// two lanes would scroll at once.
#expect(DragAutoScrollMath.reachSide < 28 / 2)
}
// MARK: Stepping the offset
@Test("One tick advances the offset by velocity × elapsed")
func nextOffsetAdvances() {
#expect(DragAutoScrollMath.nextOffset(current: 100, velocity: 600, elapsed: 0.5,
minOffset: 0, maxOffset: 1000) == 400)
#expect(DragAutoScrollMath.nextOffset(current: 100, velocity: -600, elapsed: 0.1,
minOffset: 0, maxOffset: 1000) == 40)
}
@Test("A tick clamps into the scrollable range")
func nextOffsetClamps() {
#expect(DragAutoScrollMath.nextOffset(current: 10, velocity: -800, elapsed: 1,
minOffset: 0, maxOffset: 1000) == 0)
#expect(DragAutoScrollMath.nextOffset(current: 990, velocity: 800, elapsed: 1,
minOffset: 0, maxOffset: 1000) == 1000)
// Content shorter than the viewport: nothing to scroll, pin to the top.
#expect(DragAutoScrollMath.nextOffset(current: 0, velocity: 800, elapsed: 1,
minOffset: 0, maxOffset: -120) == 0)
}
}
+632
View File
@@ -0,0 +1,632 @@
import Foundation
import Testing
@testable import Kanban
/// `BoardStore`'s drop commits the writes a released drag performs (04-interactions.md Drag and
/// drop, DRAG-REORDER.md § The drop commits).
///
/// Like every other write suite here these drive a **real store over a real temp board** and then
/// read back through the loader or the raw bytes, never through a snapshot the store handed out:
/// the interesting claims are about the files which rank landed, which folder travelled, which
/// UUID was reminted, and which sibling was left alone. `WriterFixture`, `Ident` and `Item` come
/// from `WriterTestSupport.swift`.
///
/// The geometry that produces the `index` these methods take is `DropSlotMathTests`'; here the
/// index is simply given, which is the whole point of the split.
// MARK: - Fixtures
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Three cards in the first lane, one in the second enough room for a run of two to insert
/// between siblings without either end being the answer.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
return fixture
}
/// Identities the destination board has never seen the source board's own, for every cross-board
/// case that is *not* about the import boundary. `Ident` is shared with the writer suites and
/// deliberately small; a second board needs its own namespace to be a second board at all.
private enum Foreign {
static let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
static let second = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
}
/// The board every cross-board test drags *out of*: one lane holding a live card and a tombstoned
/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry.
///
/// `colliding` puts the lane and its live card under identities the **destination** already holds,
/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity
/// either way, so a colliding arrival can prove the degradation is per folder.
@MainActor
private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
let laneName = colliding ? Ident.lane1 : Foreign.lane
let cardName = colliding ? Ident.card1 : Foreign.card
try fixture.item(laneName, Item.rich(order: "1024", title: "Imported"))
try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling"))
try fixture.item("\(laneName)/\(Foreign.trashed)", tombstoned(order: "2048", title: "Trashed"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
/// The board as the loader sees it never the store's snapshot, which a drop deliberately does not
/// touch (the one-way flow: the write lands, the watcher reloads).
private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
/// A lane's rendered card titles, in display order.
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
return lane.cards.filter { !$0.isDeleted }.compactMap(\.title.value)
}
/// A lane's rendered card folder names, in display order identity, where titles would not
/// distinguish an original from its copy.
private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
}
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
try FrontmatterDocument.parse(fixture.indexText(relativePath)).order
}
/// A file's mtime "this sibling was not rewritten", stated the way `WriteFidelityTests` states it.
private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> Date {
let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
guard let modified = attributes[.modificationDate] as? Date else {
Issue.record("no modification date for \(relativePath)")
return .distantPast
}
return modified
}
// MARK: - Within-board card moves
@MainActor
@Suite("BoardStore ▸ moveCards")
struct MoveCardsTests {
@Test("A same-lane drop rewrites only the card that moved")
func sameLaneReorder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let first = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
let second = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
// Third to the head: the index is counted with the dragged card already removed, so 0 is
// "before what is left", which is First.
store.moveCards([card3], toLane: lane1, at: 0)
#expect(try titles(lane1, in: fixture) == ["Third", "First", "Second"])
// A head insert over the remaining ranks [1024, 2048].
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card3)") == .valid(0))
// Ranks are inserted, never permuted, so the siblings' files were never opened.
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == first)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") == second)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A cross-lane drop inserts the run contiguously, in flatten order")
func crossLaneContiguousInsert() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// A Set, deliberately unordered: the run lands in *flatten* order lane `order` first,
// then card `order` so Second (lane one) precedes Fourth (lane two) whatever the Set did.
store.moveCards([card4, card2], toLane: lane1, at: 1)
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Fourth", "Third"])
#expect(try titles(lane2, in: fixture) == [], "the arrival's folder really left lane two")
#expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card4)"))
// A within-board move never remints: the UUIDs travelled unchanged.
#expect(try ids(lane1, in: fixture) == [Ident.card1, Ident.card2, Ident.card4, Ident.card3])
#expect(store.banners.oneShots.isEmpty)
}
@Test("A drop that lands where everything already is writes nothing")
func ownSlotIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let stamps = [
try stat(fixture, "\(Ident.lane1)/\(Ident.card1)"),
try stat(fixture, "\(Ident.lane1)/\(Ident.card2)"),
try stat(fixture, "\(Ident.lane1)/\(Ident.card3)"),
]
// Second's own resting slot: with it removed the lane reads [First, Third], and 1 puts it
// straight back between them.
store.moveCards([card2], toLane: lane1, at: 1)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamps[0])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") == stamps[1],
"a drag that ends where it started must not stamp modified or mint a commit")
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card3)") == stamps[2])
#expect(store.banners.oneShots.isEmpty)
}
@Test("A destination that is gone, tombstoned, or empty of members writes nothing")
func noOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root)
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
store.moveCards([card1], toLane: lane3, at: 0) // tombstoned lane
store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
store.moveCards([], toLane: lane2, at: 0) // nothing dragged
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing live
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp)
#expect(try titles(lane2, in: fixture) == ["Fourth"])
#expect(store.banners.oneShots.isEmpty)
}
@Test("An out-of-range index clamps rather than trapping")
func indexClamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// A proposal computed against a snapshot one reload old must not trap.
store.moveCards([card4], toLane: lane1, at: 99)
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "Fourth"])
}
@Test("Duplicate ranks trigger a renumber, then the run places against the fresh ladder")
func renumberFallback() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// Two cards sharing a rank: no `Double` fits between them, which is the renumber trigger.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
let store = try BoardStore(rootURL: fixture.root)
store.moveCards([card3], toLane: lane1, at: 1)
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Second"])
// The lane was compacted to the 1024 ladder first, so the interior midpoint exists again.
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024))
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card3)") == .valid(1536))
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(2048))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A read-only board refuses the drop")
func readOnlyRefuses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
store.moveCards([card3], toLane: lane1, at: 0)
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
}
}
// MARK: - Within-board -copies
@MainActor
@Suite("BoardStore ▸ copyCards")
struct CopyCardsTests {
@Test("A copy lands fresh-GUID duplicates at the drop and leaves the originals alone")
func freshGUIDsAndUntouchedOriginals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
// With First lifted the lane reads [Second, Third]; 1 is "before Third".
store.copyCards([card1], toLane: lane1, at: 1)
#expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"])
let landed = try ids(lane1, in: fixture)
#expect(landed.count == 4)
#expect(landed[2] != Ident.card1, "a copy mints a fresh UUID at every level")
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp, "the original is untouched")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A copy keeps created — a copy is a fork")
func createdIsKept() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.copyCards([card1], toLane: lane2, at: 0)
let landedIDs = try ids(lane2, in: fixture)
let landed = try #require(landedIDs.first { $0 != Ident.card4 })
let text = try fixture.indexText("\(Ident.lane2)/\(landed)")
#expect(text.contains("created: 2026-01-01T09:00:00Z"))
#expect(!text.contains("modified-by"), "a copy is an app write, so the foreign stamp is cleared")
}
@Test("A copy's ranks are placed among the originals, which are still there")
func ranksAvoidTheOriginals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Index 0 in the resting layout means "before Second" the layout the originals are lifted
// out of. The rank has to sit between First and Second, not at First's apparently vacated
// 1024, because First reappears the instant the write lands.
store.copyCards([card1], toLane: lane1, at: 0)
#expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"])
let landed = try ids(lane1, in: fixture)[1]
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(1536))
}
@Test("A multi-copy lands the run contiguously, in flatten order")
func multiCopyIsContiguous() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.copyCards([card3, card1], toLane: lane2, at: 1)
#expect(try titles(lane2, in: fixture) == ["Fourth", "First", "Third"])
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"], "originals stay")
}
@Test("Nothing droppable copies nothing")
func noOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root)
store.copyCards([card1], toLane: lane3, at: 0)
store.copyCards([], toLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Fourth"])
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - Cross-board card arrivals
@MainActor
@Suite("BoardStore ▸ receiveCards")
struct ReceiveCardsTests {
@Test("A cross-board copy lands a fresh-GUID duplicate and leaves the source alone")
func crossBoardCopy() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
operation: .copy, toLane: lane2, at: 0)
#expect(try titles(lane2, in: destination) == ["Travelling", "Fourth"])
let landedIDs = try ids(lane2, in: destination)
#expect(landedIDs.first != Foreign.card, "copies mint fresh UUIDs, always")
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"), "the original stays")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A cross-board move carries the identity and empties the source folder")
func crossBoardMove() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
operation: .move, toLane: lane2, at: 1)
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.card], "identity travels")
#expect(!source.exists("\(Foreign.lane)/\(Foreign.card)"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A moved folder whose UUID the destination already holds arrives reminted")
func importBoundaryRemints() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
// The source's card carries `card1`, which lives in the destination's first lane already.
let source = try makeSourceBoard(colliding: true)
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveCards([source.url("\(Ident.lane1)/\(Ident.card1)")],
operation: .move, toLane: lane2, at: 1)
let landed = try ids(lane2, in: destination)
#expect(landed.count == 2)
#expect(landed[1] != Ident.card1, "a colliding UUID is repaired at the import boundary")
#expect(destination.exists("\(Ident.lane1)/\(Ident.card1)"), "the resident keeps its identity")
#expect(try titles(lane2, in: destination) == ["Fourth", "Travelling"])
#expect(!source.exists("\(Ident.lane1)/\(Ident.card1)"))
}
@Test("A cross-board run lands contiguously at the drop, in the order given")
func runLandsContiguously() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
try source.item("\(Foreign.lane)/\(Foreign.second)", Item.rich(order: "3072", title: "Second traveller"))
let store = try BoardStore(rootURL: destination.root)
store.receiveCards([
source.url("\(Foreign.lane)/\(Foreign.card)"),
source.url("\(Foreign.lane)/\(Foreign.second)"),
], operation: .copy, toLane: lane1, at: 1)
#expect(try titles(lane1, in: destination)
== ["First", "Travelling", "Second traveller", "Second", "Third"])
}
@Test("Nothing droppable receives nothing")
func noOps() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: destination.root)
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
operation: .copy, toLane: lane3, at: 0)
store.receiveCards([], operation: .copy, toLane: lane2, at: 0)
#expect(try destination.entryNames(Ident.lane3) == ["index.md"])
#expect(try titles(lane2, in: destination) == ["Fourth"])
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - Cross-board lane arrivals
@MainActor
@Suite("BoardStore ▸ receiveLanes")
struct ReceiveLanesTests {
@Test("A lane copy transfers the content and strips the tombstoned cards")
func laneCopyStripsTombstones() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(Foreign.lane)], operation: .copy, at: 0)
let model = try loaded(destination)
#expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"])
let arrived = try #require(model.lanes.first)
#expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level")
#expect(arrived.cards.map(\.title.value) == ["Travelling"],
"trash isn't content — the tombstoned card did not come")
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
#expect(arrived.cards[0].id.rawValue != Foreign.card, "a copied lane's cards are new cards")
// The tombstoned original stays recoverable in the source board.
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A lane move carries its tombstoned cards whole, into the destination's trash")
func laneMoveCarriesTombstones() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(Foreign.lane)], operation: .move, at: 2)
let model = try loaded(destination)
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane],
"identity travels, and the drop position is honoured")
let arrived = try #require(model.lanes.last)
#expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.trashed])
#expect(arrived.cards[1].isDeleted, "the tombstone came along as-is")
#expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)],
"and it renders in the destination's trash")
#expect(!source.exists(Foreign.lane))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A colliding lane move remints only the folders that collide")
func laneMoveRemintsPerFolder() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
// The source lane is `lane1` holding `card1` both already live in the destination plus
// one tombstoned card whose identity is foreign.
let source = try makeSourceBoard(colliding: true)
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(Ident.lane1)], operation: .move, at: 2)
let model = try loaded(destination)
#expect(model.lanes.count == 3)
let arrived = try #require(model.lanes.last)
#expect(arrived.id.rawValue != Ident.lane1, "the colliding root was repaired")
#expect(arrived.title.value == "Imported")
let arrivedCards = arrived.cards.map(\.id.rawValue)
#expect(arrivedCards.count == 2)
#expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too")
#expect(arrivedCards[1] == Foreign.trashed, "and nothing else was — degradation is per folder")
#expect(try titles(lane1, in: destination) == ["First", "Second", "Third"],
"the residents kept their identities and their ranks")
}
@Test("Nothing to receive writes nothing")
func noOps() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([], operation: .copy, at: 0)
#expect(try loaded(destination).lanes.count == 2)
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - Drag to restore, positionally
/// A lane holding a live card, a tombstoned one, and another live one so a restore has somewhere
/// to land that is neither the head nor the tail.
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
return fixture
}
@MainActor
@Suite("BoardStore ▸ positional drag-to-restore")
struct RestoreByDragPositionTests {
@Test("The drop position sets the restored card's order")
func dropPositionSetsTheOrder() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Index 0 among the lane's two live cards: ahead of both, not back at its recorded 2048.
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0)
#expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder")
#expect(TrashModel.isEmpty(try loaded(fixture)))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A cross-lane restore lands at the drop position, not at the bottom")
func crossLanePositional() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(try order(fixture, "\(Ident.lane2)/\(Ident.card2)") == .valid(0))
let text = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
#expect(!text.contains("deleted:"))
}
@Test("An out-of-range index clamps to the lane's bottom")
func indexClamps() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 99)
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
}
}
@MainActor
@Suite("BoardStore ▸ receiveRestoredCards")
struct ReceiveRestoredCardsTests {
@Test("A cross-board restore-copy lands live and leaves the source tombstone standing")
func restoreCopyStripsDeletedAndKeepsTheOriginal() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
operation: .copy, toLane: lane2, at: 0)
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
let landedIDs = try ids(lane2, in: destination)
let landed = try #require(landedIDs.first)
#expect(landed != Foreign.trashed, "a copy out of the trash is still a copy")
let text = try destination.indexText("\(Ident.lane2)/\(landed)")
#expect(!text.contains("deleted:"), "`deleted:` is stripped on paste/duplicate/drop")
#expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork")
// The tombstoned original stays recoverable in the source board's trash.
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A cross-board restore-move clears the tombstone and the source loses the folder")
func restoreMoveClearsTheTombstone() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
operation: .move, toLane: lane2, at: 1)
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels")
let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)")
#expect(!text.contains("deleted:"))
#expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source")
#expect(TrashModel.isEmpty(try loaded(source)))
#expect(TrashModel.isEmpty(try loaded(destination)))
#expect(store.banners.oneShots.isEmpty)
}
}
+481
View File
@@ -0,0 +1,481 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `DropSlotMath` where a drag would land, as arithmetic. The model is DRAG-REORDER.md; these
/// pin it rule for rule, ported from the pathfinder's `DropSlotTests` and extended for the two
/// things Lanework has that it did not: a masonry card grid that is genuinely two-dimensional from
/// day one, and a hysteresis contract that says "hold" with `nil` rather than by echoing the
/// caller's own value back at it.
// MARK: - Zones (one axis)
/// Three cards of height 40 with an 8pt gap, starting at y = 0:
/// card 0: [0, 40] · card 1: [48, 88] · card 2: [96, 136]
/// Zone boundaries: the gap midpoints 44 and 92, then the last edge plus half a gap, 140.
/// slot 0 (, 44) · slot 1 [44, 92) · slot 2 [92, 140) · slot 3 [140, )
private let extents: [ClosedRange<CGFloat>] = [0...40, 48...88, 96...136]
private let gap: CGFloat = 8
private var boundaries: [CGFloat] { DropSlotMath.zoneBoundaries(extents: extents, gap: gap) }
@Suite("DropSlotMath ▸ zones")
struct DropSlotZoneTests {
@Test("Zone boundaries are the gap midpoints, plus half a gap past the last item")
func zoneBoundariesTile() {
#expect(boundaries == [44, 92, 140])
#expect(DropSlotMath.zoneBoundaries(extents: [], gap: gap) == [])
#expect(DropSlotMath.zoneBoundaries(extents: [10...50], gap: gap) == [54])
}
@Test("Anywhere over an item claims its slot, whatever was proposed before")
func anywhereOverAnItemClaimsIt() {
for y: CGFloat in [48, 60, 68, 80, 88] {
for current in [nil, 0, 1, 2, 3] {
#expect(DropSlotMath.containingSlot(cursor: y, boundaries: boundaries, current: current) == 1,
"cursor \(y) is over item 1 (current \(String(describing: current)))")
}
}
// The half-gap flanks belong to the zone too the zones tile with no dead space.
#expect(DropSlotMath.containingSlot(cursor: 45, boundaries: boundaries, current: 0) == 1)
#expect(DropSlotMath.containingSlot(cursor: 91, boundaries: boundaries, current: 2) == 1)
}
@Test("A zone is entered exactly at its border, and left only by entering another")
func enteredAtTheBorder() {
#expect(DropSlotMath.containingSlot(cursor: 44.0001, boundaries: boundaries, current: 0) == 1)
#expect(DropSlotMath.containingSlot(cursor: 43.9999, boundaries: boundaries, current: 1) == 0)
#expect(DropSlotMath.containingSlot(cursor: 140.0001, boundaries: boundaries, current: 2) == 3)
for y in stride(from: 44.5, through: 91.5, by: 0.5) {
#expect(DropSlotMath.containingSlot(cursor: CGFloat(y), boundaries: boundaries, current: 1) == 1,
"cursor \(y) is inside slot 1's zone; the proposal must hold")
}
}
@Test("Past the last item is the end slot")
func pastTheLastItem() {
#expect(DropSlotMath.containingSlot(cursor: 141, boundaries: boundaries, current: nil) == 3)
#expect(DropSlotMath.containingSlot(cursor: 500, boundaries: boundaries, current: 0) == 3)
}
@Test("Picking an item up over its own resting spot proposes its own slot — a no-op")
func ownSlotPickupIsANoOp() {
for y: CGFloat in [48, 55, 68, 80, 88] {
#expect(DropSlotMath.containingSlot(cursor: y, boundaries: boundaries, current: 1) == 1)
}
var index = 1
for _ in 0..<10 {
index = DropSlotMath.containingSlot(cursor: 68, boundaries: boundaries, current: index)
}
#expect(index == 1, "re-evaluating the same cursor is a fixed point")
}
@Test("A cursor on an exact boundary keeps whichever adjoining slot is proposed")
func exactBoundaryTie() {
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 1) == 1)
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 2) == 2)
var index = 1
for _ in 0..<10 {
index = DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: index)
}
#expect(index == 1, "the boundary pixel is a fixed point, so the shadow cannot oscillate")
// A non-adjacent current has no claim on the tie; the border rule wins.
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 0) == 2)
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: nil) == 2)
}
@Test("Degenerate inputs are total")
func degenerateInputs() {
#expect(DropSlotMath.containingSlot(cursor: 123, boundaries: [], current: nil) == 0)
#expect(DropSlotMath.containingSlot(cursor: 123, boundaries: [], current: 0) == 0)
// An index from a snapshot one reload old is ignored rather than trusted.
#expect(DropSlotMath.containingSlot(cursor: 116, boundaries: boundaries, current: 99) == 2)
#expect(DropSlotMath.containingSlot(cursor: 116, boundaries: boundaries, current: -1) == 2)
}
}
// MARK: - Span-capped triggers
/// Lanes along x with an 8pt gap: a 1× (100), a 3× (320), a 1× (100).
/// lane 0: [0, 100] · lane 1: [108, 428] · lane 2: [436, 536]
/// Zone boundaries: 104, 432, 540. Dragging a 1× lane (span 100):
/// slot 1's trigger = [104, 104 + 100 + 8 = 212]; (212, 432) is dead.
@Suite("DropSlotMath ▸ span-capped triggers")
struct SpanCappedSlotTests {
private let extents: [ClosedRange<CGFloat>] = [0...100, 108...428, 436...536]
private let gap: CGFloat = 8
private let narrowSpan: CGFloat = 100
private func slot(_ cursor: CGFloat, current: Int?, span: CGFloat? = nil) -> Int? {
DropSlotMath.slot(cursor: cursor, extents: extents, gap: gap,
draggedSpan: span ?? narrowSpan, current: current)
}
@Test("A slot triggers over the footprint the dragged run would actually occupy")
func triggerIsTheFutureFootprint() {
// The near side of the wide lane where the dragged lane would land claims slot 1 from
// any prior proposal. (x = 104 exactly is the boundary pixel, owned by the tie rule.)
for x: CGFloat in [105, 110, 160, 212] {
for current in [nil, 0, 1, 2, 3] {
#expect(slot(x, current: current) == 1,
"cursor \(x) is inside slot 1's trigger (current \(String(describing: current)))")
}
}
}
@Test("The far side of a wider item is dead, and holds the proposal")
func deadRegionHolds() {
for x: CGFloat in [213, 300, 420, 431] {
#expect(slot(x, current: 0) == nil, "dead region at \(x) must hold, not re-propose")
#expect(slot(x, current: 2) == nil)
#expect(slot(x, current: 3) == nil)
}
// Repeated evaluation in the dead region never moves the proposal.
var current = 0
for _ in 0..<10 { current = slot(300, current: current) ?? current }
#expect(current == 0)
}
@Test("A dead region with no valid prior proposal snaps to the containing zone")
func freshEntryFallsBackToTheContainingZone() {
// A drag in flight over a live target must always have some landing spot the fresh
// cross-board entry, and the first sample after a reload invalidated the last proposal.
#expect(slot(300, current: nil) == 1)
#expect(slot(300, current: 99) == 1)
#expect(slot(300, current: -1) == 1)
}
@Test("A dragged run at least as large as the item it crosses behaves uncapped")
func wideRunIsUncapped() {
for (x, expected): (CGFloat, Int) in [(50, 0), (300, 1), (420, 1), (500, 2), (600, 3)] {
#expect(slot(x, current: 0, span: 320) == expected, "cursor \(x)")
}
}
@Test("The terminal slots are never capped")
func terminalSlotsAreUncapped() {
#expect(slot(-50, current: 2) == 0, "before the first item, slot 0 is the only reading")
#expect(slot(600, current: 0) == 3, "past the last item, appending is the only reading")
#expect(slot(10_000, current: nil) == 3)
}
@Test("The exact-boundary tie survives the cap")
func boundaryTieStillHolds() {
#expect(slot(104, current: 0) == 0)
#expect(slot(104, current: 1) == 1)
}
@Test("A multi-drag's span includes the gaps between its members")
func multiDragSpanIncludesInnerGaps() {
// Two 1× lanes dragged together: span = 100 + 8 + 100 = 208, so the wide lane's trigger
// stretches to 104 + 208 + 8 = 320.
#expect(slot(300, current: 0, span: 208) == 1)
#expect(slot(321, current: 0, span: 208) == nil, "beyond the run's footprint is still dead")
}
@Test("An empty container is always index zero")
func emptyContainer() {
#expect(DropSlotMath.slot(cursor: 42, extents: [], gap: gap, draggedSpan: 100, current: nil) == 0)
}
}
// MARK: - The lane strip
/// `standard = 100`, `gap = 10`, matching `LaneReorderMathTests`: a 1× slot is 100 wide, a 2× is
/// 210 and a 3× is 320, and the strip's outer margin is one gap, so the first slot starts at 10.
@Suite("DropSlotMath ▸ the lane strip")
struct LaneSlotTests {
private let standard: CGFloat = 100
private let gap: CGFloat = 10
@Test("The resting extents are LaneLayoutMath's own arithmetic, in range form")
func restingExtents() {
let extents = DropSlotMath.laneExtents(unitCounts: [1, 3, 1], standard: standard, gap: gap)
#expect(extents == [10...110, 120...440, 450...550])
#expect(DropSlotMath.laneExtents(unitCounts: [], standard: standard, gap: gap).isEmpty)
// The centres agree with `LaneReorderMath.centre`, which reads the same layout the two
// must never drift, since the drag's replica offsets from one and its proposal from the
// other.
for index in 0..<3 {
let centre = LaneReorderMath.centre(ofLaneAt: index, unitCounts: [1, 3, 1],
standard: standard, gap: gap)
#expect(centre == (extents[index].lowerBound + extents[index].upperBound) / 2)
}
}
@Test("A dragged run's span is its slots plus the gaps between them")
func runSpan() {
#expect(DropSlotMath.laneRunSpan(unitCounts: [], standard: standard, gap: gap) == 0)
#expect(DropSlotMath.laneRunSpan(unitCounts: [1], standard: standard, gap: gap) == 100)
#expect(DropSlotMath.laneRunSpan(unitCounts: [3], standard: standard, gap: gap) == 320)
#expect(DropSlotMath.laneRunSpan(unitCounts: [1, 1], standard: standard, gap: gap) == 210)
#expect(DropSlotMath.laneRunSpan(unitCounts: [1, 2, 1], standard: standard, gap: gap) == 430)
}
@Test("A 1× lane crossing a 3× lane does not reflow until it reaches where it would land")
func widthAwareTriggers() {
// Remaining lanes [1, 3, 1]; dragging a 1× lane. Slot 1's trigger runs from 115 (the wide
// lane's leading edge less half a gap) for 100 + 10 225. (225, 445) is dead.
func slot(_ x: CGFloat, current: Int?) -> Int? {
DropSlotMath.laneSlot(cursorX: x, restingUnits: [1, 3, 1], draggedUnits: [1],
standard: standard, gap: gap, current: current)
}
#expect(slot(130, current: 0) == 1, "the wide lane's leading edge is where the drop lands")
#expect(slot(225, current: 0) == 1, "the cap's far edge still triggers")
#expect(slot(300, current: 0) == nil, "the wide lane's far side holds the proposal")
#expect(slot(300, current: nil) == 1, "with nothing to hold, the containing zone answers")
#expect(slot(500, current: 0) == 2)
#expect(slot(600, current: 0) == 3, "past the last lane: the end slot, uncapped")
#expect(slot(-100, current: 2) == 0, "before the first lane: slot 0, uncapped")
}
@Test("A wide dragged run reaches further, and a run of two reaches further still")
func runSpanWidensTheTrigger() {
// Dragging a 3× lane (span 320): the cap covers the whole of the wide lane's zone.
#expect(DropSlotMath.laneSlot(cursorX: 300, restingUnits: [1, 3, 1], draggedUnits: [3],
standard: standard, gap: gap, current: 0) == 1)
// Two 1× lanes together (span 210): the trigger reaches 115 + 210 + 10 = 335.
#expect(DropSlotMath.laneSlot(cursorX: 330, restingUnits: [1, 3, 1], draggedUnits: [1, 1],
standard: standard, gap: gap, current: 0) == 1)
#expect(DropSlotMath.laneSlot(cursorX: 340, restingUnits: [1, 3, 1], draggedUnits: [1, 1],
standard: standard, gap: gap, current: 0) == nil)
}
@Test("An empty strip proposes slot zero")
func emptyStrip() {
#expect(DropSlotMath.laneSlot(cursorX: 200, restingUnits: [], draggedUnits: [1],
standard: standard, gap: gap, current: nil) == 0)
}
}
// MARK: - The masonry's resting grid
@Suite("MasonryPlacement")
struct MasonryPlacementTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
@Test("Children are assigned round-robin and each column stacks independently")
func roundRobinStacking() {
let frames = placement.frames(heights: [40, 60, 30, 20, 50])
#expect(frames == [
CGRect(x: 0, y: 0, width: 100, height: 40), // column 0, row 0
CGRect(x: 108, y: 0, width: 100, height: 60), // column 1, row 0
CGRect(x: 0, y: 48, width: 100, height: 30), // column 0, row 1 under card 0 only
CGRect(x: 108, y: 68, width: 100, height: 20), // column 1, row 1 under card 1 only
CGRect(x: 0, y: 86, width: 100, height: 50),
])
}
@Test("The placement matches an independent reading of the documented rule")
func differentialAgainstTheStatedRule() {
// A second implementation of the rule as 03-board-ui.md states it "child `i` column
// `i % columns`, each column stacks top-aligned and independently" written from the
// words rather than from the code. `MasonryLayout` places subviews through
// `MasonryPlacement`, so agreeing here is agreeing with what is drawn.
func naive(_ heights: [CGFloat], columns: Int, width: CGFloat, spacing: CGFloat,
origin: CGPoint) -> [CGRect] {
var stacks = [[CGFloat]](repeating: [], count: columns)
var frames: [CGRect] = []
for (index, height) in heights.enumerated() {
let column = index % columns
let stacked = stacks[column].reduce(0) { $0 + $1 + spacing }
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
y: origin.y + stacked,
width: width, height: height))
stacks[column].append(height)
}
return frames
}
let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12]
for columns in 1...4 {
let origin = CGPoint(x: 17, y: 23)
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
spacing: 8, origin: origin)
#expect(placement.frames(heights: heights)
== naive(heights, columns: columns, width: 100, spacing: 8, origin: origin),
"\(columns) interior columns")
}
}
@Test("The reported height is the tallest column's stack")
func heightIsTheTallestColumn() {
let heights: [CGFloat] = [40, 60, 30, 20, 50]
let frames = placement.frames(heights: heights)
// Column 0 stacks 40 + 8 + 30 + 8 + 50 = 136; column 1 stacks 60 + 8 + 20 = 88.
#expect(placement.height(heights: heights) == 136)
#expect(placement.height(heights: heights) == frames.map(\.maxY).max())
#expect(placement.height(heights: []) == 0)
#expect(placement.frames(heights: []).isEmpty)
}
@Test("Column and row invert to the logical index")
func columnRowInversion() {
for index in 0..<9 {
#expect(placement.index(column: placement.column(of: index),
row: placement.row(of: index)) == index)
}
#expect(placement.column(of: 3) == 1)
#expect(placement.row(of: 3) == 1)
}
@Test("Column width divides the lane, and a degenerate column count clamps to one")
func columnWidthArithmetic() {
#expect(MasonryPlacement.columnWidth(totalWidth: 316, columnCount: 3, spacing: 8) == 100)
#expect(MasonryPlacement.columnWidth(totalWidth: 100, columnCount: 1, spacing: 8) == 100)
// A lane narrower than its own spacings never proposes a negative width.
#expect(MasonryPlacement.columnWidth(totalWidth: 4, columnCount: 3, spacing: 8) == 0)
#expect(MasonryPlacement(columnCount: 0, columnWidth: 100, spacing: 8).columnCount == 1)
}
}
// MARK: - The masonry's insertion index
/// A 2-wide lane of five cards, 100pt columns and 8pt spacing:
/// column 0 (x 0100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
/// column 1 (x 108208): card 1 [0, 60] · card 3 [68, 88]
/// Column bands meet at 104. Column 0's zone boundaries are 44, 82, 140; column 1's are 64, 92.
@Suite("DropSlotMath ▸ the card masonry")
struct CardSlotTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
private func slot(_ x: CGFloat, _ y: CGFloat, current: Int?, dragged: CGFloat = 30) -> Int? {
DropSlotMath.cardSlot(cursor: CGPoint(x: x, y: y), placement: placement,
heights: heights, draggedHeight: dragged, current: current)
}
@Test("A cursor over a card claims that card's logical position")
func cursorOverACardClaimsItsLogicalPosition() {
let probes: [(CGFloat, CGFloat, Int)] = [
(20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4),
]
for (x, y, expected) in probes {
for current in [nil, 0, 1, 2, 3, 4, 5] {
#expect(slot(x, y, current: current) == expected,
"(\(x), \(y)) should claim slot \(expected) (current \(String(describing: current)))")
}
}
}
@Test("Column, then row, then r · C + c — the round-robin inverse")
func columnAndRowComposeTheIndex() {
// Column 1's second row is logical position 3, not "the fourth thing the cursor passed":
// the index is the lane's card order, which is what the store writes and what VoiceOver
// traverses (10-accessibility.md's logical-order rule).
#expect(slot(150, 75, current: nil) == 3)
#expect(placement.column(of: 3) == 1)
#expect(placement.row(of: 3) == 1)
}
@Test("Below any column's last card is the end slot")
func belowAColumnIsTheEndSlot() {
#expect(slot(20, 200, current: nil) == 5, "below column 0 — clamped past the end")
#expect(slot(150, 200, current: nil) == 5, "below column 1 — exactly the end")
#expect(slot(20, 200, current: 1) == 5)
}
@Test("Above and beside the grid clamp inward to the nearest column")
func clampingAtTheEdges() {
#expect(slot(20, -40, current: nil) == 0, "the lane header targets the first row")
#expect(slot(150, -40, current: nil) == 1)
#expect(slot(-60, 20, current: nil) == 0, "the lane's leading padding is still column 0")
#expect(slot(400, 20, current: nil) == 1, "and its trailing padding column 1")
}
@Test("A dead region below a tall card holds the proposal")
func deadRegionHolds() {
// Dragging a 10pt card: slot 4's trigger runs from 82 for 10 + 8 100, so (100, 140) is
// the far side of card 4's zone and changes nothing.
#expect(slot(20, 95, current: 0, dragged: 10) == 4, "inside the footprint, the slot triggers")
#expect(slot(20, 120, current: 0, dragged: 10) == nil)
#expect(slot(20, 120, current: 2, dragged: 10) == nil)
// With nothing to hold, the containing zone answers a drag in flight has a landing spot.
#expect(slot(20, 120, current: nil, dragged: 10) == 4)
var current = 0
for _ in 0..<10 { current = slot(20, 120, current: current, dragged: 10) ?? current }
#expect(current == 0)
}
@Test("A cursor on a zone boundary keeps whichever adjoining slot is proposed")
func boundaryTie() {
// y = 44 is the boundary between column 0's slots 0 and 1. A 60pt dragged card reaches
// past it from either side, so the cap does not decide and the tie rule does.
#expect(slot(20, 44, current: 0, dragged: 60) == 0)
#expect(slot(20, 44, current: 2, dragged: 60) == 2, "slot 2 is column 0's row 1")
var index = 0
for _ in 0..<10 { index = slot(20, 44, current: index, dragged: 60) ?? index }
#expect(index == 0, "the boundary pixel is a fixed point")
}
@Test("Re-evaluating a resting hover is a fixed point — own-slot pickup never reflows")
func ownSlotPickupIsANoOp() {
var index = 2
for _ in 0..<10 { index = slot(20, 60, current: index) ?? index }
#expect(index == 2)
}
@Test("A proposal in another column never holds this one")
func aProposalInAnotherColumnDoesNotHold() {
// Slot 1 lives in column 1; a cursor deep in column 0's dead region cannot "hold" it,
// because holding a proposal the cursor is nowhere near would strand the shadow.
#expect(slot(20, 120, current: 1, dragged: 10) == 4)
}
@Test("A one-column lane behaves like a plain vertical list")
func oneColumnLane() {
let column = MasonryPlacement(columnCount: 1, columnWidth: 200, spacing: 8)
func slot(_ y: CGFloat, current: Int?) -> Int? {
DropSlotMath.cardSlot(cursor: CGPoint(x: 100, y: y), placement: column,
heights: [40, 40], draggedHeight: 40, current: current)
}
#expect(slot(20, current: nil) == 0)
#expect(slot(60, current: nil) == 1)
#expect(slot(120, current: nil) == 2)
#expect(slot(44, current: 0) == 0)
#expect(slot(44, current: 1) == 1)
}
@Test("An empty lane proposes slot zero, and a lane with fewer cards than columns still appends")
func degenerateGrids() {
#expect(DropSlotMath.cardSlot(cursor: CGPoint(x: 10, y: 10), placement: placement,
heights: [], draggedHeight: 30, current: nil) == 0)
// One card, two columns: column 1 is empty, and its only slot is the end.
#expect(DropSlotMath.cardSlot(cursor: CGPoint(x: 150, y: 10), placement: placement,
heights: [40], draggedHeight: 30, current: nil) == 1)
}
}
// MARK: - Applying a proposal
@Suite("DropSlotMath ▸ applying a proposal")
struct AppliedTests {
@Test("A run lifts out and re-inserts contiguously, in the order it was given")
func contiguousInsertPreservesOrder() {
let items = ["a", "b", "c", "d", "e"]
#expect(DropSlotMath.applied(items, moving: ["b", "d"], to: 0) == ["b", "d", "a", "c", "e"])
#expect(DropSlotMath.applied(items, moving: ["b", "d"], to: 3) == ["a", "c", "e", "b", "d"])
#expect(DropSlotMath.applied(items, moving: ["d", "b"], to: 1) == ["a", "d", "b", "c", "e"],
"the run's own order is preserved, not re-derived")
}
@Test("An index counted with the run removed makes the resting position a no-op")
func ownSlotIsIdentity() {
let items = ["a", "b", "c"]
#expect(DropSlotMath.applied(items, moving: ["b"], to: 1) == items)
}
@Test("Out-of-range indices clamp rather than trap")
func clamping() {
let items = ["a", "b", "c"]
#expect(DropSlotMath.applied(items, moving: ["a"], to: -5) == ["a", "b", "c"])
#expect(DropSlotMath.applied(items, moving: ["a"], to: 99) == ["b", "c", "a"])
#expect(DropSlotMath.applied(items, moving: [], to: 1) == items)
}
}
+47
View File
@@ -165,6 +165,53 @@ struct RanksTests {
#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() {
+13 -11
View File
@@ -369,13 +369,15 @@ struct TrashDragRestoreTests {
let store = try BoardStore(rootURL: fixture.root)
let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
store.restoreByDrag(cardID: card2, intoLane: lane1)
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 1)
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!after.contains("deleted"))
// The `order` is untouched, so the card returns where it was rather than at the bottom
// the position-perfect restore a pure-view trash makes possible.
// Dropped at index 1 past lane one's single live card, which is exactly where the card's
// recorded 2048 already puts it. The rank the drop names and the rank on disk agree, so no
// `order` is written at all: the `order` line comes through byte-for-byte and the card
// returns where it was, the position-perfect restore a pure-view trash makes possible.
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
#expect(untouchedLines(after) == untouchedLines(original))
}
@@ -386,14 +388,14 @@ struct TrashDragRestoreTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane2)
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)"))
let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
#expect(!after.contains("deleted"))
// m5-drag: the positional drop replaces this append. Lane two's one visible card is at
// 1024, so the arrival lands at 2048 the Writer's own append over visible siblings.
// Dropped at index 1 lane two's one visible card is at 1024, so the drop's own rank is
// the append 2048, carried by the move rather than left to the Writer to compute.
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
let model = try BoardLoader.load(boardRoot: fixture.root).model
@@ -413,13 +415,13 @@ struct TrashDragRestoreTests {
// A tombstoned destination lane is never a drop target (04 Drag and drop: "a card is
// never filed under a `deleted:` parent").
store.restoreByDrag(cardID: card2, intoLane: lane3)
store.restoreByDrag(cardID: card2, intoLane: lane3, at: 0)
// A lane that is not on the board at all.
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless))
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless), at: 0)
// A card that is not a trash row: live, and for `card4` hidden by its lane rather than
// by its own flag, so it has no row to drag in the first place.
store.restoreByDrag(cardID: card1, intoLane: lane2)
store.restoreByDrag(cardID: card4, intoLane: lane1)
store.restoreByDrag(cardID: card1, intoLane: lane2, at: 0)
store.restoreByDrag(cardID: card4, intoLane: lane1, at: 0)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified)
@@ -434,7 +436,7 @@ struct TrashDragRestoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
store.restoreByDrag(cardID: card2, intoLane: lane2)
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1)
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:"))