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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user