Files
lanework/KanbanTests/DragSessionTests.swift
T
rzen 3f4125e324 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
2026-07-28 07:51:05 -04:00

479 lines
22 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: - 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, 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
}
}