Files
lanework/KanbanTests/DragSessionTests.swift
T
rzen 33bf425f25 Drop a card on the shown trash to delete it
04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:12:09 -04:00

643 lines
29 KiB
Swift

import AppKit
import Foundation
import Testing
@testable import Kanban
/// The drag session's **value** halves — the pasteboard payload, the locality model, and the
/// committed overlay's hand-off condition (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
///
/// The session object itself, the drop delegates and the gestures are not unit-testable — they are
/// deliberately thin over these three, plus `DropSlotMath`'s arithmetic, which is why the split falls
/// where it does.
// MARK: - The payload
@Suite("DragPayload")
struct DragPayloadTests {
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
DragPayload(
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
kind: kind,
side: side,
items: [
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
]
)
}
@Test("A payload round-trips through its JSON representation unchanged")
func roundTrip() throws {
for kind in [DragKind.cards, .lanes] {
for side in [Liveness.live, .trashed] {
let original = Self.payload(kind: kind, side: side)
let data = try #require(original.encoded())
#expect(DragPayload(data: data) == original)
}
}
}
@Test("Garbage decodes to nothing rather than to an empty drag")
func garbageDecodesToNil() {
#expect(DragPayload(data: Data("not json".utf8)) == nil)
#expect(DragPayload(data: Data()) == nil)
}
@Test("The ids, folders and root are read back off the strings, in flatten order")
func derivedValues() {
let payload = Self.payload()
#expect(payload.ids == [ItemID(rawValue: "aaa"), ItemID(rawValue: "bbb")])
#expect(payload.folders.map(\.path) == [
"/Boards/Work.kanban/lane/aaa",
"/Boards/Work.kanban/lane/bbb"
])
#expect(payload.rootURL.path == "/Boards/Work.kanban")
}
@Test("The plain-text representation is the dragged titles, one per line")
func plainText() {
// The stray-drop-into-a-text-editor fallback. An untitled item renders as the board renders
// it — "Untitled" is a rendering, never a value (03-board-ui.md § Card face).
#expect(Self.payload().plainText == "First\nUntitled")
}
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
func sideSurvives() throws {
let data = try #require(Self.payload(side: .trashed).encoded())
#expect(DragPayload(data: data)?.side.liveness == .trashed)
}
}
// MARK: - Locality
@Suite("DragLocality")
struct DragLocalityTests {
// Instance members, not `static`: every case below names them bare, and a static member is not
// reachable unqualified from an instance method. Swift Testing builds a fresh instance per test,
// so these are as constant either way.
private let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
private let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
private let none: NSEvent.ModifierFlags = []
private let option: NSEvent.ModifierFlags = [.option]
private let command: NSEvent.ModifierFlags = [.command]
@Test("Roots compare by their standardized path, so the same board is the same board")
func rootComparison() {
#expect(DragLocality.isSameBoard(here, here))
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/./Work.kanban/")))
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
#expect(!DragLocality.isSameBoard(here, there))
}
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
@Test("Locality picks the default — within is a move, across is a copy")
func theDefault() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
}
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
func modifiersOverride() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
// The no-ops.
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
}
@Test("⌘ wins over ⌥ when both are held")
func commandWinsOverOption() {
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
#expect(DragLocality.operation(
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
}
/// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there:
/// the drag stays a clean reorder and the badge never shows copy."
@Test("A within-board lane drag ignores ⌥ entirely")
func laneDragsNeverCopyWithinTheirBoard() {
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
#expect(
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
"a within-board lane drag is a reorder whatever is held"
)
}
// Across boards the lane obeys the ordinary grammar again.
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
}
/// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its
/// own board the default is the restore — a move, no badge; across boards the default is the live
/// copy that leaves the tombstone standing. ⌘ forces the true restore-move either way, and ⌥ the
/// live copy either way.
@Test("A trash row drags as a restore at home and as a copy-out abroad")
func trashDragDefaults() {
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
}
}
// MARK: - Dropping on the trash
/// **The delete gesture's gate** (04-interactions.md ▸ The trash, settled 2026-07-28: "dropping a
/// live card on the shown trash deletes it").
///
/// One pure function decides it, and it is asked twice — once at hover for the shadow and once at
/// release for the write — so every clause below is a claim about both.
@Suite("TrashDrop")
struct TrashDropTests {
/// The accepted session, with one clause at a time knocked out by the cases.
private func accepts(
kind: DragKind? = .cards,
side: Liveness = .live,
isWithinBoard: Bool = true,
operation: TransferOperation = .move,
isTrashShown: Bool = true,
acceptsMutations: Bool = true
) -> Bool {
TrashDrop.accepts(
kind: kind,
side: side,
isWithinBoard: isWithinBoard,
operation: operation,
isTrashShown: isTrashShown,
acceptsMutations: acceptsMutations
)
}
/// "The shadow always takes the topmost position — which the sort makes honest, not arbitrary:
/// the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top."
@Test("The landing is the topmost row, always")
func theTopmostRow() {
#expect(TrashDrop.landingIndex == 0)
}
@Test("A live, same-board, unmodified card drag is the one session the trash takes")
func theOneItTakes() {
#expect(accepts())
// ⌘ forces move, which is already the default here, so it changes nothing.
#expect(accepts(operation: .move))
}
/// "Lanes are not deliverable this way (a lane drag proposes only lane slots)."
@Test("A lane drag never proposes into the trash")
func lanesAreNotDeliverable() {
#expect(!accepts(kind: .lanes))
// And no session at all is no proposal either — the column is inert between drags.
#expect(!accepts(kind: nil))
}
/// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes
/// nothing, so it never proposes.
@Test("A trash row dropped back on the trash is refused")
func theTrashedSideIsRefused() {
#expect(!accepts(side: .trashed))
#expect(!accepts(side: .trashed, isWithinBoard: false))
}
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
/// would be a transfer-and-delete compound, which the design names nowhere.
@Test("A foreign board's card is refused")
func crossBoardIsRefused() {
#expect(!accepts(isWithinBoard: false))
// Not even with ⌘, which forces the move a cross-board drag would otherwise only copy.
#expect(!accepts(isWithinBoard: false, operation: .move))
}
/// Copying into the trash is not a thing — and the alternative would be tombstoning an original
/// the copy grammar had just promised to leave exactly where it was.
@Test("⌥ is refused rather than reinterpreted")
func optionCopyIsRefused() {
#expect(!accepts(operation: .copy))
}
/// "The trash stays undroppable-into while hidden, like every gesture."
@Test("Hidden, the trash is invisible to the gesture")
func hiddenIsInert() {
#expect(!accepts(isTrashShown: false))
}
@Test("The mutating-gesture rule applies, like every other write the pointer can start")
func theLockAndTheEditorRefuse() {
#expect(!accepts(acceptsMutations: false))
}
}
// MARK: - The committed-overlay hold
@Suite("CommittedHold")
struct CommittedHoldTests {
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
private static let hold = CommittedHold(boardRoot: here, generation: 7)
@Test("The hold stands until the destination board applies a *newer* snapshot")
func retiredByTheNextSnapshot() {
// The generation at the commit is the one already on screen — it is the pre-drop arrangement,
// and retiring on it would drop the overlay before the write has round-tripped.
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 7))
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 8))
// *Any* snapshot hands off, not just the app-mediated echo: a foreign one that lands first
// re-grounds everything anyway.
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 99))
}
@Test("A reload on another board says nothing about this one")
func otherBoardsDoNotRetireIt() {
#expect(!Self.hold.isRetired(byRoot: Self.there, generation: 99))
}
@Test("The board is matched by identity, not by string")
func rootMatchingUsesTheLocalityComparison() {
#expect(Self.hold.isRetired(byRoot: URL(fileURLWithPath: "/Boards/./Work.kanban/"), generation: 8))
}
@Test("A stale generation never retires it")
func staleGenerations() {
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0))
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6))
}
/// The figure 03-board-ui.md fixes for a hold with no echo coming: long enough for a write plus a
/// watcher round trip, short enough that a refused write does not leave the board drawing an
/// arrangement it never got.
@Test("The deadline is the design's own figure")
func theTimeoutFigure() {
#expect(CommittedHold.timeout == .milliseconds(1500))
}
/// The two questions the effective operation settles at once, which is why the hold carries it
/// rather than a pair of flags (`CommittedHold`).
@Test("A move takes the originals away and keeps their identities; a copy does neither")
func theOperationDecidesBothHalves() {
var hold = Self.hold
hold.operation = .move
#expect(hold.removesOriginals)
#expect(hold.keepsIdentity)
hold.operation = .copy
#expect(!hold.removesOriginals)
#expect(!hold.keepsIdentity)
}
}
// MARK: - The settle
/// **The drop settle** (03-board-ui.md § Motion, sharpened 2026-07-28): what the session renders
/// between the release and the echo. The claims here are the pure state the board's surfaces read —
/// what the proposal's slot draws (`DragSession.cardLanding`) and which originals stay lifted out
/// (`hiddenMembers`) — so the whole ruling is checkable without a view: "at release the shadow is
/// replaced by the dropped card(s) drawn in place immediately … a lingering shadow over a hidden
/// card is the hold failing its one job".
///
/// A **real store over a real temp board**, like the write suites: `commit` names the destination
/// store, and the session's own re-grounding reads that store's transient state, so a stub would be
/// standing in for exactly the thing under test. Nothing here writes.
@MainActor
@Suite("The drop settle")
struct DropSettleTests {
private static let lane1 = ItemID(rawValue: Ident.lane1)
private static let card1 = ItemID(rawValue: Ident.card1)
private static let card2 = ItemID(rawValue: Ident.card2)
/// Two cards in the first lane — enough for a run of two, and for one of them to vanish
/// mid-flight while the other still lands.
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.lane2, Item.rich(order: "2048", title: "Doing"))
return fixture
}
/// A session mid-drag: `members` picked up out of `lane1`, proposing into it at `index`.
private func proposing(
_ store: BoardStore,
members: [(id: ItemID, title: String?)] = [(card1, "First")],
at index: Int = 2
) -> DragSession {
let session = DragSession()
pickUp(session, from: store, members: members)
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: index))
return session
}
/// The pickup half, on a session that may already have had a life — the second drag in
/// `aRetiredHoldsTimeoutIsCancelled` is the whole reason it is separable.
private func pickUp(
_ session: DragSession,
from store: BoardStore,
members: [(id: ItemID, title: String?)] = [(card1, "First")]
) {
session.beginCards(
members.map(\.id),
folders: members.map {
store.rootURL
.appendingPathComponent(Ident.lane1, isDirectory: true)
.appendingPathComponent($0.id.rawValue, isDirectory: true)
},
titles: members.map(\.title),
heights: members.map { _ in 44 },
side: .live,
source: store
)
}
/// Polls for `condition`, because the timeout's discard is a `Task` on this very actor: the test
/// has to yield for it to run at all. Bounded, so a discard that never comes fails rather than
/// hangs.
private func settles(_ condition: () -> Bool) async -> Bool {
for _ in 0..<200 {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(5))
}
return condition()
}
// MARK: In flight
@Test("While the drag is in flight the slot is a run of shadows and the originals are lifted out")
func inFlightDrawsShadows() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
#expect(!session.isSettled)
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.run == .shadows)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
}
// MARK: The settle
@Test("A settled move draws the dropped card at the proposal, and draws no shadow")
func settledMoveDrawsTheCard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.commit(into: store, survivors: [0], operation: .move)
#expect(session.isSettled)
let landing = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1))
// The slot has not moved — only what it contains has, which is what keeps the settle out of
// every animation key on the board.
#expect(landing.index == 2)
let drop = try #require(landing.dropped)
#expect(drop.items == [DroppedItem(id: Self.card1, title: "First")])
// A within-board move: the arriving card wears the identity it travelled under, so the
// overlay's slot can key by it and the echo is a content swap inside one element.
#expect(drop.keepsIdentity)
#expect(drop.isLocal)
// The original stays lifted, because the write really did take it away — the overlay is
// drawing it at its landing slot instead.
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
}
@Test("A settled copy puts the originals back in the same render pass that draws the copies")
func settledCopyRestoresTheOriginals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.commit(into: store, survivors: [0], operation: .copy)
// A copy left them exactly where they were, and the arrangement the hold renders says so.
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
let drop = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.dropped)
#expect(drop.items.map(\.id) == [Self.card1])
// Fresh GUIDs are coming, so no slot may claim one: the landing keys positionally.
#expect(!drop.keepsIdentity)
}
@Test("The run the overlay draws is the run the commit wrote — a vanished member is not drawn")
func onlySurvivorsAreDrawn() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store, members: [(Self.card1, "First"), (Self.card2, nil)])
// Rule 3 of the re-grounding trio: a partly emptied drag drops the survivors, and the
// overlay must show exactly those.
session.commit(into: store, survivors: [1], operation: .move)
let drop = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.dropped)
#expect(drop.items == [DroppedItem(id: Self.card2, title: nil)])
}
@Test("A settled release is past retargeting: a late callback cannot move or withdraw it")
func settledProposalsAreFinal() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.commit(into: store, survivors: [0], operation: .move)
session.propose(nil)
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2)
}
// MARK: The trash's landing
/// A session proposing into the trash column rather than into a lane — the delete gesture
/// (04-interactions.md ▸ The trash).
private func proposingIntoTheTrash(_ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")]) -> DragSession {
let session = DragSession()
pickUp(session, from: store, members: members)
session.propose(DropTarget(
boardRoot: store.rootURL,
container: .trash,
index: TrashDrop.landingIndex
))
return session
}
@Test("The trash's shadow opens at the topmost row, and no lane draws one")
func trashInFlightDrawsTheTopRow() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposingIntoTheTrash(store)
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
#expect(landing.index == 0)
#expect(landing.run == .shadows)
// The proposal names one container and one only: the lane the cards came out of draws
// nothing, and neither does the strip.
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
#expect(session.stripProposal(onBoardRooted: store.rootURL) == nil)
// And they are still lifted out of the lane while the drag is in flight, as ever.
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
}
/// The settle, at the one landing whose cards would otherwise wink out of existence: the write
/// takes them off the live side, so the trash has to draw them from the instant of release.
@Test("A settled trash drop draws the tombstoned rows on top and keeps the originals lifted")
func trashSettleDrawsTheRows() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")])
// A delete is committed as the move it is — the write really did take the originals away.
session.commit(into: store, survivors: [0, 1], operation: .move)
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
#expect(landing.index == 0, "the slot does not move at the settle — only what it contains")
let drop = try #require(landing.dropped)
#expect(drop.items == [
DroppedItem(id: Self.card1, title: "First"),
DroppedItem(id: Self.card2, title: "Second")
])
// A tombstone remints nothing, so the settled rows may wear the cards' own identities and the
// echo reload swaps content inside one element rather than removing and inserting.
#expect(drop.keepsIdentity)
#expect(drop.isLocal)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
}
@Test("A lane session never draws a trash landing")
func laneSessionsHaveNoTrashLanding() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
session.beginLanes(
[Self.lane1],
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
titles: ["Todo"],
units: [1],
source: store
)
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
#expect(session.trashLanding(onBoardRooted: store.rootURL) == nil)
}
// MARK: The hand-off
@Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again")
func handOffClearsEverything() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.commit(into: store, survivors: [0], operation: .move)
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
#expect(!session.isSettled)
#expect(!session.isActive)
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
}
@Test("Ending the session outright ends the hold with it")
func endClearsTheHold() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.commit(into: store, survivors: [0], operation: .move)
session.end()
#expect(!session.isSettled)
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
}
// MARK: The timeout — the failed write's path
/// "A failed write discards the proposal and the board animates back to snapshot order"
/// (03-board-ui.md § Motion). A write refused outright produces no reload at all, so the deadline
/// is the only thing standing between the board and an arrangement it never got.
@Test("A hold with no echo coming times out, and the board is left with its snapshot order")
func theTimeoutDiscardsTheHold() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
// The seam: the real figure is `CommittedHold.timeout`, and waiting it out would be 1.5 s of
// wall clock in the suite for a claim about the discard rather than about the clock.
session.holdTimeout = .milliseconds(20)
session.commit(into: store, survivors: [0], operation: .move)
#expect(session.isSettled)
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
#expect(!session.isActive)
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
}
/// The discard's own body, called directly — the half of the timeout that is a decision rather
/// than a wait, and the guard that makes the wait harmless.
@Test("The discard ends the hold it was armed for, and no other")
func theDiscardEndsOnlyItsOwnHold() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.commit(into: store, survivors: [0], operation: .move)
let hold = try #require(session.hold)
session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999))
#expect(session.isSettled, "a hold this session is not holding is not this session's to end")
session.expire(hold)
#expect(!session.isSettled)
#expect(!session.isActive)
}
@Test("A retired hold's deadline never reaches the next drag")
func aRetiredHoldsTimeoutIsCancelled() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposing(store)
session.holdTimeout = .milliseconds(20)
session.commit(into: store, survivors: [0], operation: .move)
// The echo lands well inside the deadline, and the user starts another drag immediately —
// the lifecycle trap the watchdog was written for, at the hold's end of the session.
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
pickUp(session, from: store, members: [(Self.card2, "Second")])
try? await Task.sleep(for: .milliseconds(80))
#expect(session.isActive, "the retired hold's deadline must not end the drag that followed it")
#expect(session.hold == nil)
}
}
// MARK: - Reading a landing
extension DropLanding {
/// The dropped run, or `nil` while the slot is still a run of shadows — a test-side convenience
/// so a claim about the settle reads as one line rather than as a `case let` dance.
var dropped: Dropped? {
if case let .dropped(drop) = run { return drop }
return nil
}
}