The modifier flip (⌥ copy / ⌘ move) now reflows the source board once: a copy re-admits the dragged originals into the resting layout, standing dimmed in place, and a move lifts them out as before. This retires DRAG-REORDER.md's operation-blind carve-out and makes 04-interactions.md's "originals stay" true in flight, not just at echo. The one seam is DragSession.hiddenMembers reading the observed operation. Consequences carried honestly: copyCards now takes its index in the lane's full rendered space (the zones counted the originals, so the geometry's number is the writer's number — the old neighbour remap is deleted); resolveOperation freezes under the committed hold exactly as propose does, fixing a real bug where a settled within-board ⌥-copy drew the move arrangement until the echo and visibly re-shuffled. Stationary flips still wait for the next dropUpdated (their own Backlog card). Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1008 lines
47 KiB
Swift
1008 lines
47 KiB
Swift
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
|
|
|
|
/// 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 two cards, plus a card sitting
|
|
/// in the source board's own `.trash/` for the cross-board restore cases.
|
|
///
|
|
/// `colliding` puts the lane and its first card under identities the **destination** already holds,
|
|
/// which is the import boundary's whole question; the second 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.second)", Item.rich(order: "2048", title: "Second"))
|
|
try fixture.item(".trash/\(Foreign.trashed)", Item.rich(order: "1024", 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.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.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, or a set that names nothing, writes nothing")
|
|
func noOps() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
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 there
|
|
|
|
#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
|
|
|
|
/// **A copy's index is counted among the lane's full rendered cards, originals included** — unlike
|
|
/// `moveCards`', which is counted among the cards its run vacates (DRAG-REORDER.md § Resting-layout
|
|
/// zones, ruled 2026-08-01). That is not an arbitrary split: a copy leaves its originals in the
|
|
/// source board's resting layout for the whole drag (`DragSession.hiddenMembers`), so the zones that
|
|
/// produced the number counted them too, and each writer consumes the number in the space its own
|
|
/// gesture was showing. `DragRestingLayoutTests` pins the layout half; these pin the write half.
|
|
@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)")
|
|
|
|
// The lane the user is looking at reads [First, Second, Third] — the ⌥-copy's originals
|
|
// stay in it — so 2 is "before Third".
|
|
store.copyCards([card1], toLane: lane1, at: 2)
|
|
|
|
#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)
|
|
}
|
|
|
|
/// **The drop lands exactly where the shadow showed, below the dragged run included.** This is
|
|
/// the case the index space is actually about: the shadow sits *after* the original, so the
|
|
/// slot number the geometry produced counts the original on its way there, and a writer that
|
|
/// re-read the number against the originals-removed layout would push the copy one slot too far
|
|
/// — off the end of a three-card lane, in this fixture.
|
|
@Test("A copy below the dragged run lands at the shadow's slot, not past it")
|
|
func landsBelowTheRun() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Dragging First, ⌥ held, with the shadow between Second and Third: the drawn lane is
|
|
// [First, Second, ▮, Third], so the proposal is slot 2.
|
|
store.copyCards([card1], toLane: lane1, at: 2)
|
|
|
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"])
|
|
let landed = try ids(lane1, in: fixture)[2]
|
|
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(2560),
|
|
"between Second and Third, where the shadow was")
|
|
}
|
|
|
|
/// The end slot, in the same space: one past the last card of the layout on screen appends.
|
|
@Test("The terminal slot appends, counted among the originals like every other slot")
|
|
func theTerminalSlotAppends() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.copyCards([card1], toLane: lane1, at: 3)
|
|
|
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "First"])
|
|
let landed = try ids(lane1, in: fixture)[3]
|
|
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(4096))
|
|
}
|
|
|
|
@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 1 is "between First and Second" in the lane as drawn, and the original First is
|
|
// genuinely still sitting at 1024 — so the rank has to land strictly inside that gap. There
|
|
// is no apparently-vacated slot to choose in, which is the whole point of counting the
|
|
// index among the originals rather than around them.
|
|
store.copyCards([card1], toLane: lane1, at: 1)
|
|
|
|
#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))
|
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024),
|
|
"the original keeps its rank — nothing was written to it")
|
|
}
|
|
|
|
/// Slot 0 in the copy's space is genuinely *before* the original, which the old
|
|
/// originals-removed space could not name at all: its 0 meant "before the first survivor",
|
|
/// i.e. after the original.
|
|
@Test("Slot 0 lands the copy in front of its own original")
|
|
func slotZeroLandsInFront() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.copyCards([card1], toLane: lane1, at: 0)
|
|
|
|
#expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"])
|
|
let landed = try ids(lane1, in: fixture)[0]
|
|
#expect(landed != Ident.card1, "the copy is the one in front")
|
|
let rank = try #require(order(fixture, "\(Ident.lane1)/\(landed)").value)
|
|
#expect(rank < 1024, "a whole gap below the original, which keeps its own 1024")
|
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024))
|
|
}
|
|
|
|
@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() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.copyCards([card1], toLane: ItemID(rawValue: Ident.lane3), at: 0)
|
|
store.copyCards([], toLane: lane2, at: 0)
|
|
|
|
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
|
#expect(!fixture.exists(Ident.lane3))
|
|
#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() }
|
|
let store = try BoardStore(rootURL: destination.root)
|
|
|
|
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
|
|
operation: .copy, toLane: ItemID(rawValue: Ident.lane3), at: 0)
|
|
store.receiveCards([], operation: .copy, toLane: lane2, at: 0)
|
|
|
|
#expect(!destination.exists(Ident.lane3))
|
|
#expect(try titles(lane2, in: destination) == ["Fourth"])
|
|
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Within-board lane moves
|
|
|
|
/// A three-lane board, so a run of two can land at either end or between the survivors.
|
|
@MainActor
|
|
private func makeThreeLaneBoard() 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.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
|
return fixture
|
|
}
|
|
|
|
private func laneTitles(_ fixture: WriterFixture) throws -> [String] {
|
|
try loaded(fixture).lanes.filter { !$0.isDeleted }.compactMap(\.title.value)
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ moveLanes")
|
|
struct MoveLanesTests {
|
|
|
|
@Test("A run of lanes lands contiguously at the drop, in board order")
|
|
func runLandsContiguously() throws {
|
|
let fixture = try makeThreeLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let untouched = try stat(fixture, Ident.lane2)
|
|
|
|
// Todo and Done together, dropped at 0 — "before what is left", which is Doing. The two
|
|
// arrive adjacent and in board order even though they were not adjacent to begin with.
|
|
store.moveLanes([lane1, lane3], toIndex: 0)
|
|
|
|
#expect(try laneTitles(fixture) == ["Todo", "Done", "Doing"])
|
|
// Ranks are inserted, never permuted: the lane that did not move was never opened.
|
|
#expect(try stat(fixture, Ident.lane2) == untouched)
|
|
#expect(try order(fixture, Ident.lane2) == .valid(2048))
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("A run dropped past the survivors appends in order")
|
|
func runAppends() throws {
|
|
let fixture = try makeThreeLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.moveLanes([lane1, lane2], toIndex: 1)
|
|
|
|
#expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"])
|
|
}
|
|
|
|
@Test("A single lane behaves exactly as the singular command does")
|
|
func singleLane() throws {
|
|
let fixture = try makeThreeLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.moveLanes([lane3], toIndex: 0)
|
|
|
|
#expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"])
|
|
}
|
|
|
|
@Test("A drag that lands where everything already is writes nothing")
|
|
func noOpDropWritesNothing() throws {
|
|
let fixture = try makeThreeLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let before = try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) }
|
|
|
|
store.moveLanes([lane1], toIndex: 0)
|
|
store.moveLanes([lane1, lane2], toIndex: 0)
|
|
store.moveLanes([], toIndex: 1)
|
|
store.moveLanes([ItemID(rawValue: Ident.indexless)], toIndex: 0)
|
|
|
|
#expect(try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) } == before)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("An out-of-range index clamps rather than trapping")
|
|
func indexClamps() throws {
|
|
// A store apiece: a write does not touch the snapshot (the one-way flow), so two drops
|
|
// against one store would both be computed against the pre-drop board.
|
|
let high = try makeThreeLaneBoard()
|
|
defer { high.tearDown() }
|
|
try BoardStore(rootURL: high.root).moveLanes([lane1], toIndex: 99)
|
|
#expect(try laneTitles(high) == ["Doing", "Done", "Todo"])
|
|
|
|
let low = try makeThreeLaneBoard()
|
|
defer { low.tearDown() }
|
|
try BoardStore(rootURL: low.root).moveLanes([lane3], toIndex: -5)
|
|
#expect(try laneTitles(low) == ["Done", "Todo", "Doing"])
|
|
}
|
|
}
|
|
|
|
// MARK: - Cross-board lane arrivals
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ receiveLanes")
|
|
struct ReceiveLanesTests {
|
|
|
|
/// 04-interactions.md ▸ Drag and drop, resettled 2026-07-28: "A lane carries exactly its cards —
|
|
/// the trash is board-level (`.trash/`), so there is nothing lane-nested to strip or carry: copy
|
|
/// and ⌘-drag move alike transfer the lane's folder as it is; the old tombstone-stripping rule is
|
|
/// retired with the tombstone model."
|
|
@Test("A lane copy transfers the whole lane, minting fresh identities at every level")
|
|
func laneCopyRemintsThroughout() 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", "Second"], "nothing to strip")
|
|
#expect(arrived.cards.allSatisfy { $0.id.rawValue != Foreign.card },
|
|
"a copied lane's cards are new cards")
|
|
#expect(model.trash.isEmpty, "the source's trash is board-level and never travels with a lane")
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("A lane move carries its cards whole, identity and all")
|
|
func laneMoveCarriesItsCards() 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.second],
|
|
"a lane carries exactly its cards: nothing lane-nested to strip or carry")
|
|
#expect(model.trash.isEmpty, "and nothing arrived in the destination's own 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.second, "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: - Restore is an ordinary move out
|
|
|
|
/// A lane holding two cards, plus one card in the board's `.trash/` — so a restore has somewhere to
|
|
/// land that is neither the head nor the tail.
|
|
///
|
|
/// **There is no tombstone anywhere in it.** "Restoring is an ordinary move out: drag a trash card
|
|
/// into any lane at any position … there is no restore-specific machinery and no Put Back"
|
|
/// (03-board-ui.md § Trash, resettled 2026-07-28), so the fixture is an ordinary board plus a
|
|
/// reserved folder.
|
|
@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.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"))
|
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
|
|
return fixture
|
|
}
|
|
|
|
/// Restoring out of the trash — **through `moveCards` and `copyCards`, with no method of its own**.
|
|
///
|
|
/// That is the claim these tests exist to pin. The tombstone model needed `restoreByDrag` and
|
|
/// `receiveRestoredCards` because a restore had to clear a key as well as place a rank; a
|
|
/// materialized trash makes it "an ordinary move" (03-board-ui.md § Trash), so the ordinary drop
|
|
/// commits take a trash-side source and the retired pair is gone.
|
|
@MainActor
|
|
@Suite("BoardStore ▸ restore by move-out")
|
|
struct RestoreByMoveOutTests {
|
|
|
|
@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 cards: ahead of both.
|
|
store.moveCards([card2], toLane: 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(".trash/\(Ident.card2)"), "the folder physically left the trash")
|
|
#expect(try loaded(fixture).trash.isEmpty)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@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.moveCards([card2], toLane: lane1, at: 99)
|
|
|
|
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
|
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
|
|
}
|
|
|
|
@Test("The identity travels, exactly as it does for any move")
|
|
func identityTravels() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.moveCards([card2], toLane: lane2, at: 0)
|
|
|
|
#expect(try ids(lane2, in: fixture) == [Ident.card2, Ident.card4])
|
|
#expect(!fixture.exists(".trash/\(Ident.card2)"))
|
|
// Nothing about the restored card says it was ever deleted: there is no key to remove,
|
|
// because there is no key.
|
|
#expect(!(try fixture.indexText("\(Ident.lane2)/\(Ident.card2)").contains("deleted:")))
|
|
}
|
|
|
|
@Test("A multi-card restore lands the run contiguously, in one bracket")
|
|
func multiRestore() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
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.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
|
// The trash's own order is its display order, and since 2026-07-31 that is `modified`
|
|
// descending (01-storage-format.md § Deletion) — `card3` above `card2`, newest-first.
|
|
try fixture.item(
|
|
".trash/\(Ident.card2)",
|
|
"---\nschema: 1\ntitle: TrashedA\norder: 2048\nmodified: 2026-05-01T09:00:00Z\n---\n")
|
|
try fixture.item(
|
|
".trash/\(Ident.card3)",
|
|
"---\nschema: 1\ntitle: TrashedB\norder: 1024\nmodified: 2026-05-03T09:00:00Z\n---\n")
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.moveCards([card3, card2], toLane: lane2, at: 0)
|
|
|
|
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
|
|
"the trash's own order is the landing order")
|
|
#expect(try loaded(fixture).trash.isEmpty)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("⌥ copies out and leaves the original in the trash")
|
|
func copyOutLeavesTheOriginal() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.copyCards([card2], toLane: lane2, at: 0)
|
|
|
|
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
|
|
let landed = try #require(try ids(lane2, in: fixture).first)
|
|
#expect(landed != Ident.card2, "a copy out of the trash is still a copy")
|
|
#expect(fixture.exists(".trash/\(Ident.card2)"), "the original stays in the trash")
|
|
#expect(try loaded(fixture).trash.count == 1)
|
|
}
|
|
|
|
@Test("A card that is not in the trash and an empty list both write nothing")
|
|
func skipsWhatIsNotThere() throws {
|
|
let fixture = try makeTrashBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0)
|
|
store.moveCards([], toLane: lane2, at: 0)
|
|
|
|
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
}
|
|
|
|
/// The cross-board half — **`receiveCards`, with no restore variant** (04-interactions.md ▸ The
|
|
/// trash: "Dropped on *another* board it follows the copy default — a live copy lands there, the
|
|
/// original stays in the source trash; ⌘-drag forces the true cross-board restore-move").
|
|
@MainActor
|
|
@Suite("BoardStore ▸ cross-board restore")
|
|
struct CrossBoardRestoreTests {
|
|
|
|
@Test("A cross-board copy out of the trash lands a fresh identity and leaves the original")
|
|
func copyLeavesTheOriginal() 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(".trash/\(Foreign.trashed)")],
|
|
operation: .copy, toLane: lane2, at: 0)
|
|
|
|
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
|
|
let landed = try #require(try ids(lane2, in: destination).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("created: 2026-01-01T09:00:00Z"), "a copy is a fork")
|
|
|
|
#expect(source.exists(".trash/\(Foreign.trashed)"), "the original stays in the source trash")
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("A ⌘-drag move out of another board's trash carries the identity and empties it")
|
|
func moveCarriesTheIdentity() 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(".trash/\(Foreign.trashed)")],
|
|
operation: .move, toLane: lane2, at: 1)
|
|
|
|
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels")
|
|
#expect(!source.exists(".trash/\(Foreign.trashed)"), "the card left the source trash")
|
|
#expect(try loaded(source).trash.isEmpty)
|
|
#expect(try loaded(destination).trash.isEmpty)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Restoring a trashed lane
|
|
|
|
/// A board whose trash holds a **lane row** — the opaque unit, subtree intact (03-board-ui.md §
|
|
/// Trash) — beside an ordinary trashed card, so the restore's rank arithmetic has a strip to land
|
|
/// on and the container has more than one kind in it.
|
|
@MainActor
|
|
private func makeTrashedLaneBoard() 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.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item(
|
|
".trash/\(Ident.lane3)",
|
|
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
|
|
)
|
|
try fixture.item(".trash/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "1024", title: "Freight"))
|
|
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
|
|
return fixture
|
|
}
|
|
|
|
/// **Drag-to-restore at the lane level** (04-interactions.md ▸ The trash: "dropping … a trashed lane
|
|
/// row onto its own board's strip — is an ordinary move to the drop position"), which is
|
|
/// `BoardStore.restoreLanes` — an arrival's rank arithmetic with a within-board move's identity
|
|
/// posture and an undo step, since 13-native-undo.md's inverse inventory names "restore-by-move →
|
|
/// move back in".
|
|
@MainActor
|
|
@Suite("BoardStore ▸ restoring a trashed lane")
|
|
struct RestoreLaneTests {
|
|
|
|
@Test("The drop slot sets the restored lane's order, and its cards ride along")
|
|
func dropSlotSetsTheOrder() throws {
|
|
let fixture = try makeTrashedLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Slot 1: between the board's two lanes.
|
|
store.restoreLanes([lane3], toIndex: 1)
|
|
|
|
let model = try loaded(fixture)
|
|
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane3, Ident.lane2])
|
|
#expect(model.trashedLanes.isEmpty)
|
|
#expect(!fixture.exists(".trash/\(Ident.lane3)"), "the folder physically left the trash")
|
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"), "the freight came back inside it")
|
|
#expect(model.trash.map(\.id.rawValue) == [Ident.card3], "the column's cards are untouched")
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("The identity travels — a within-board restore is a move, never an import")
|
|
func identityTravels() throws {
|
|
let fixture = try makeTrashedLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.restoreLanes([lane3], toIndex: 99)
|
|
|
|
// The lane keeps its UUID and so does its card: the import boundary's remint is for
|
|
// *arrivals from another board*, and a row coming out of this board's own trash is not one.
|
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
|
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
|
|
#expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along")
|
|
}
|
|
|
|
@Test("Its undo is the ordinary move back in, at the `order` the row was carrying")
|
|
func undoMovesItBackIn() throws {
|
|
let fixture = try makeTrashedLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
|
|
store.restoreLanes([lane3], toIndex: 0)
|
|
#expect(history.undoActionName == "Move Lane")
|
|
|
|
history.undo()
|
|
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card2)"), "back in, subtree intact")
|
|
#expect(!fixture.exists(Ident.lane3))
|
|
#expect(try order(fixture, ".trash/\(Ident.lane3)") == .valid(512), "at the rank it was holding")
|
|
|
|
history.redo()
|
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
|
|
#expect(try loaded(fixture).trashedLanes.isEmpty)
|
|
}
|
|
|
|
@Test("A row that is not in the trash, and an empty set, write nothing")
|
|
func skipsWhatIsNotThere() throws {
|
|
let fixture = try makeTrashedLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let untouched = try stat(fixture, Ident.lane1)
|
|
|
|
store.restoreLanes([lane1], toIndex: 0)
|
|
store.restoreLanes([], toIndex: 0)
|
|
|
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2])
|
|
#expect(try stat(fixture, Ident.lane1) == untouched)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// The keyboard restore's write (04-interactions.md ▸ The trash: "⌘X in the trash, ⌘V … a trashed
|
|
/// lane pastes after the anchor lane"), whose armed-cut path lands in `receiveLanes` with a
|
|
/// source folder inside this board's own `.trash/`.
|
|
///
|
|
/// **The identity has to survive that**, which is what the trash-aware source-root derivation
|
|
/// buys: `.trash/` counts in the destination's identity scan, so a restore mistaken for an import
|
|
/// would find the row's own UUID resident and remint the very lane it was restoring.
|
|
@Test("A within-board paste out of the trash keeps the lane's identity")
|
|
func pasteRestoreKeepsTheIdentity() throws {
|
|
let fixture = try makeTrashedLaneBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.receiveLanes(
|
|
[.folder(fixture.url(".trash/\(Ident.lane3)"))],
|
|
operation: .move,
|
|
at: 2,
|
|
normalizingLooseFiles: true
|
|
)
|
|
|
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
|
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
|
|
#expect(try loaded(fixture).trashedLanes.isEmpty)
|
|
}
|
|
}
|
|
|
|
/// The cross-board half — the ordinary arrival, exactly as for a live lane: "Dropped on *another*
|
|
/// board it follows the copy default … ⌘-drag forces the true cross-board restore-move"
|
|
/// (04-interactions.md ▸ The trash).
|
|
@MainActor
|
|
@Suite("BoardStore ▸ cross-board lane restore")
|
|
struct CrossBoardLaneRestoreTests {
|
|
|
|
@Test("A cross-board copy out of the trash mints fresh identities and leaves the original")
|
|
func copyLeavesTheOriginal() throws {
|
|
let destination = try makeBoard()
|
|
defer { destination.tearDown() }
|
|
let source = try makeTrashedLaneBoard()
|
|
defer { source.tearDown() }
|
|
let store = try BoardStore(rootURL: destination.root)
|
|
|
|
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .copy, at: 99)
|
|
|
|
let model = try loaded(destination)
|
|
let landed = try #require(model.lanes.last)
|
|
#expect(landed.title.value == "Done")
|
|
#expect(landed.id.rawValue != Ident.lane3, "a copy out of the trash is still a copy")
|
|
#expect(landed.cards.map(\.title.value) == ["Freight"])
|
|
#expect(source.exists(".trash/\(Ident.lane3)"), "the original stays in the source trash")
|
|
}
|
|
|
|
@Test("A ⌘-drag move carries the lane's identity and empties the source trash")
|
|
func moveCarriesTheIdentity() throws {
|
|
let destination = try makeBoard()
|
|
defer { destination.tearDown() }
|
|
let source = try makeTrashedLaneBoard()
|
|
defer { source.tearDown() }
|
|
let store = try BoardStore(rootURL: destination.root)
|
|
|
|
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .move, at: 0)
|
|
|
|
let landed = try #require(loaded(destination).lanes.first)
|
|
#expect(landed.id.rawValue == Ident.lane3, "the lane's identity travels")
|
|
// Its card carried `Ident.card2`, which this destination already holds — so the import
|
|
// boundary remints that folder and only that folder, per-folder at the finest grain.
|
|
#expect(landed.cards.map(\.title.value) == ["Freight"])
|
|
#expect(landed.cards.map(\.id.rawValue) != [Ident.card2])
|
|
#expect(!source.exists(".trash/\(Ident.lane3)"))
|
|
#expect(try loaded(source).trashedLanes.isEmpty)
|
|
}
|
|
}
|