Render the dropped card at release, and pin the hold's timeout

03's sharpened settle rule: rendering the arrangement means rendering
the card — at release the shadow swaps for the dropped card(s) drawn in
place immediately, the appear never waiting for the echo reload. The
committed hold now carries the landing (ids, payload titles, operation)
and surfaces read one DropLanding seam: within-board moves draw the
real faces at their proposed slots under the arriving card's own key,
so the echo is an invisible content swap; cross-board card arrivals
draw payload-titled faces keyed positionally, so the echo reads as an
ordinary arrival. Cross-board lane arrivals deliberately keep their
shadow until the echo — a lane's face is a whole column with no honest
payload equivalent. The 1500 ms failed-write timeout is now seamed
(injectable duration, extracted expire) and pinned by tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:51:05 -04:00
parent 1487b391ad
commit 3f4125e324
7 changed files with 745 additions and 52 deletions
+294
View File
@@ -181,4 +181,298 @@ struct CommittedHoldTests {
#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, laneID: 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, laneID: Self.lane1, index: 0))
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2)
}
// 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
}
}