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
860 lines
40 KiB
Swift
860 lines
40 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, container: ItemContainer = .board) -> DragPayload {
|
|
DragPayload(
|
|
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
|
|
kind: kind,
|
|
container: container,
|
|
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 container in [ItemContainer.board, .trash] {
|
|
let original = Self.payload(kind: kind, container: container)
|
|
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 container survives the round trip, because it is what makes a trash drag a trash drag")
|
|
func containerSurvives() throws {
|
|
let data = try #require(Self.payload(container: .trash).encoded())
|
|
#expect(DragPayload(data: data)?.container == .trash)
|
|
}
|
|
}
|
|
|
|
// 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, container: .board, isWithinBoard: true, modifiers: none) == .move)
|
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy)
|
|
#expect(DragLocality.operation(kind: .lanes, container: .board, 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, container: .board, isWithinBoard: true, modifiers: option) == .copy)
|
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move)
|
|
// The no-ops.
|
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move)
|
|
#expect(DragLocality.operation(kind: .cards, container: .board, 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, container: .board, 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, container: .board, 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, container: .board, isWithinBoard: false, modifiers: option) == .copy)
|
|
#expect(DragLocality.operation(kind: .lanes, container: .board, 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, container: .trash, isWithinBoard: true, modifiers: none) == .move)
|
|
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy)
|
|
#expect(DragLocality.operation(
|
|
kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move)
|
|
#expect(DragLocality.operation(kind: .cards, container: .trash, 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,
|
|
container: ItemContainer = .board,
|
|
isWithinBoard: Bool = true,
|
|
operation: TransferOperation = .move,
|
|
isTrashShown: Bool = true,
|
|
acceptsMutations: Bool = true
|
|
) -> Bool {
|
|
TrashDrop.accepts(
|
|
kind: kind,
|
|
container: container,
|
|
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))
|
|
}
|
|
|
|
/// "Dropping a live card — **or lane** — on the shown trash deletes it … a lane drag over the
|
|
/// shown trash proposes the delete alongside its strip slots" (04-interactions.md ▸ The trash,
|
|
/// lanes extended 2026-07-29, retiring "a lane drag proposes only lane slots").
|
|
@Test("A live lane drag proposes the delete too — and no session proposes nothing")
|
|
func lanesAreDeliverable() {
|
|
#expect(accepts(kind: .lanes))
|
|
// Every other clause binds the lane exactly as it binds the card: a trashed row dragged out
|
|
// is not deletable back into the place it already is, a foreign board's lane is refused, and
|
|
// a hidden column takes nothing.
|
|
#expect(!accepts(kind: .lanes, container: .trash))
|
|
#expect(!accepts(kind: .lanes, isWithinBoard: false))
|
|
#expect(!accepts(kind: .lanes, isTrashShown: false))
|
|
#expect(!accepts(kind: .lanes, acceptsMutations: false))
|
|
// And no session at all is no proposal either — the column is inert between drags.
|
|
#expect(!accepts(kind: nil))
|
|
}
|
|
|
|
/// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it
|
|
/// never proposes.
|
|
@Test("A trash card dropped back on the trash is refused")
|
|
func theTrashContainerIsRefused() {
|
|
#expect(!accepts(container: .trash))
|
|
#expect(!accepts(container: .trash, 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 mixed-kind drag out of the trash
|
|
|
|
/// **"A mixed-kind drag never leaves the trash"** (04-interactions.md ▸ The trash, ruled 2026-07-31
|
|
/// with kind-blind trash selection): "pickup is allowed — the selection is legal — but every
|
|
/// out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining
|
|
/// the rule … the refused drag ends like any refusal, rows staying put".
|
|
///
|
|
/// The refusal itself lives in `BoardDropContext.commitDrop`, which needs a live window and is not
|
|
/// unit-testable — the same split every other drop suite makes. What is testable is the whole of
|
|
/// what the refusal is *made* of: the flag a pickup records, and the notice the release posts.
|
|
@MainActor
|
|
@Suite("The mixed-kind drag out of the trash")
|
|
struct MixedTrashDragTests {
|
|
|
|
private static let card1 = ItemID(rawValue: Ident.card1)
|
|
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
|
|
|
/// One lane and one trashed card — enough for a store to exist and a session to name folders.
|
|
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(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed"))
|
|
return fixture
|
|
}
|
|
|
|
/// Pickup is allowed, and the flag is what travels instead of the rows that cannot ride a
|
|
/// per-kind payload — so nothing falls silently out of the drag.
|
|
@Test("A pickup records whether its selection spanned both kinds")
|
|
func theFlagTravels() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = DragSession()
|
|
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
|
|
|
|
session.beginCards([Self.card1], folders: [folder], heights: [44], container: .trash, source: store)
|
|
#expect(!session.mixesKinds, "an ordinary trash-card drag carries no flag")
|
|
|
|
session.beginCards(
|
|
[Self.card1], folders: [folder], heights: [44],
|
|
container: .trash, source: store, mixesKinds: true
|
|
)
|
|
#expect(session.mixesKinds)
|
|
#expect(session.container == .trash)
|
|
|
|
// The lane level records it the same way, and a trashed lane row's session is in `.trash` —
|
|
// which is what routes its release to the restore rather than to a strip permutation.
|
|
session.beginLanes(
|
|
[Self.lane1], folders: [folder], units: [1],
|
|
container: .trash, source: store, mixesKinds: true
|
|
)
|
|
#expect(session.mixesKinds)
|
|
#expect(session.container == .trash)
|
|
|
|
// And an ordinary strip drag is unaffected: board container, no flag.
|
|
session.beginLanes([Self.lane1], folders: [folder], units: [1], source: store)
|
|
#expect(!session.mixesKinds)
|
|
#expect(session.container == .board)
|
|
}
|
|
|
|
/// The notice is 04's own sentence, and it is a **loss row** — nothing failed and no write was
|
|
/// attempted, but the gesture the user made did not happen (the `postSkippedFolders` register).
|
|
@Test("The release's notice is the rule, in the design's own words")
|
|
func theNoticeExplainsTheRule() {
|
|
let banners = BannerCenter()
|
|
banners.postMixedTrashDrag()
|
|
|
|
#expect(banners.losses.map(\.message)
|
|
== ["Cards and lanes leave the trash separately \u{2014} restore one kind at a time"])
|
|
#expect(banners.oneShots.isEmpty, "no write failed — this is not an error row")
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// MARK: - The committed-overlay hold, in the session
|
|
|
|
/// **What the session renders between the release and the echo** (DRAG-REORDER.md § The
|
|
/// committed-overlay hold): the write is in flight and the snapshot has not moved, so the session
|
|
/// keeps drawing the arrangement it was showing — the shadows at their landing slots, the originals
|
|
/// lifted out — until the destination store applies its next snapshot, or the deadline says none is
|
|
/// coming. The claims here are the pure state the board's surfaces read (`laneProposal`,
|
|
/// `trashProposal`, `hiddenMembers`), so the whole ruling is checkable without a view.
|
|
///
|
|
/// 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 committed hold")
|
|
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`.
|
|
///
|
|
/// The titles ride along only so the fixtures read as the cards they name — nothing in the
|
|
/// session reads them.
|
|
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)
|
|
},
|
|
heights: members.map { _ in 44 },
|
|
container: .board,
|
|
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 shadow run opens at the proposal 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.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
|
}
|
|
|
|
// MARK: The hold
|
|
|
|
/// "The session flips from proposing to committed and keeps rendering the arrangement it was
|
|
/// showing": the write is on its way but the snapshot has not moved, so nothing about the
|
|
/// release may change what is on screen — the shadows stay at their landing slot and the
|
|
/// originals stay lifted out until the echo reload brings the real faces.
|
|
@Test("A committed hold keeps the shadows at their landing slot and the originals lifted out")
|
|
func theHoldKeepsTheArrangement() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = proposing(store)
|
|
|
|
session.commit(into: store)
|
|
|
|
#expect(session.isSettled)
|
|
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
|
}
|
|
|
|
@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)
|
|
|
|
session.propose(nil)
|
|
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
|
|
|
|
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 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)
|
|
|
|
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
|
// The proposal names one container and one only: the lane the cards came out of draws
|
|
// nothing, and neither does the strip.
|
|
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
|
#expect(session.stripProposal(onBoardRooted: store.rootURL) == nil)
|
|
// And they are lifted out of the lane, as ever.
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
|
}
|
|
|
|
/// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep
|
|
/// the space they landed in until the echo brings the real tombstones.
|
|
@Test("A committed trash drop keeps its shadow rows on top and the originals lifted")
|
|
func trashHoldKeepsTheRows() 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")])
|
|
|
|
session.commit(into: store)
|
|
|
|
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
|
#expect(session.shadowCount == 2)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
|
}
|
|
|
|
/// The column draws the delete gesture's shadow for a **lane** session too (lanes extended
|
|
/// 2026-07-29): the accessor asks "is this proposal mine", and the kind question belongs to
|
|
/// `TrashDrop.accepts`, which is asked at hover and again at release.
|
|
@Test("A lane session's trash proposal reaches the column, and only on its own board")
|
|
func laneSessionsProposeIntoTheTrash() 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)],
|
|
units: [1],
|
|
source: store
|
|
)
|
|
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
|
|
|
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
|
#expect(session.shadowCount == 1)
|
|
// Another board's column draws nothing, like every other proposal accessor.
|
|
#expect(session.trashProposal(onBoardRooted: URL(filePath: "/tmp/other-board")) == 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)
|
|
|
|
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
|
|
|
|
#expect(!session.isSettled)
|
|
#expect(!session.isActive)
|
|
#expect(session.laneProposal(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)
|
|
|
|
session.end()
|
|
|
|
#expect(!session.isSettled)
|
|
#expect(session.laneProposal(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)
|
|
#expect(session.isSettled)
|
|
|
|
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
|
|
#expect(!session.isActive)
|
|
#expect(session.laneProposal(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)
|
|
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)
|
|
|
|
// 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: - What the source board's resting layout holds
|
|
|
|
/// **The resting layout follows the effective operation** (DRAG-REORDER.md § Resting-layout zones,
|
|
/// ruled 2026-08-01; 04-interactions.md ▸ Drag and drop: "originals stay" for both copies): a move
|
|
/// lifts the dragged run out of the source board, a copy leaves it standing there, because that is
|
|
/// what the release will actually leave behind. The whole rule is `hiddenMembers(onBoardRooted:)`,
|
|
/// and every surface that builds a resting layout — the lane masonries, the strip, the drop zones —
|
|
/// reads it, so pinning it here pins all of them.
|
|
///
|
|
/// The modifiers are passed to `resolveOperation` rather than held down: it is the same seam
|
|
/// `DragLocalityTests` uses one level down, and it is what makes the flip checkable without a
|
|
/// keyboard.
|
|
@MainActor
|
|
@Suite("The source board's resting layout")
|
|
struct DragRestingLayoutTests {
|
|
|
|
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
|
private static let card1 = ItemID(rawValue: Ident.card1)
|
|
private static let card2 = ItemID(rawValue: Ident.card2)
|
|
|
|
private let none: NSEvent.ModifierFlags = []
|
|
private let option: NSEvent.ModifierFlags = [.option]
|
|
private let command: NSEvent.ModifierFlags = [.command]
|
|
|
|
/// Another board entirely — the right-hand side of every cross-board resolution below. It needs
|
|
/// no fixture: locality compares paths, and nothing here reads the foreign board's contents.
|
|
private let elsewhere = URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true)
|
|
|
|
/// One lane holding two cards, plus a trashed card so a `.trash`-container session has something
|
|
/// real to name.
|
|
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(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
|
|
return fixture
|
|
}
|
|
|
|
/// A card session over `members`, picked up out of `lane1`.
|
|
private func cardSession(
|
|
_ store: BoardStore,
|
|
members: [ItemID] = [card1],
|
|
container: ItemContainer = .board
|
|
) -> DragSession {
|
|
let session = DragSession()
|
|
session.beginCards(
|
|
members,
|
|
folders: members.map {
|
|
store.rootURL
|
|
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
|
.appendingPathComponent($0.rawValue, isDirectory: true)
|
|
},
|
|
heights: members.map { _ in 44 },
|
|
container: container,
|
|
source: store
|
|
)
|
|
return session
|
|
}
|
|
|
|
// MARK: The two layouts
|
|
|
|
@Test("A move lifts the dragged run out — the source board shows what it is giving away")
|
|
func aMoveLiftsTheRunOut() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store, members: [Self.card1, Self.card2])
|
|
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
|
|
|
// ⌘ over a foreign board is the cross-board move, and it lifts them out just the same.
|
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
|
}
|
|
|
|
/// The card the user filed: ⌥ says "leave the originals here", so the originals are drawn here.
|
|
@Test("A copy hides nothing — the originals re-admit into the source board's layout")
|
|
func aCopyReAdmitsTheOriginals() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store, members: [Self.card1, Self.card2])
|
|
|
|
// The within-board ⌥-copy.
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
|
|
|
// And the cross-board default, which is a copy with no modifier at all.
|
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
|
}
|
|
|
|
/// The flip is a deliberate user action and the one-shot reflow is its feedback — which means
|
|
/// the layout has to answer *both* ways, as many times as the user asks.
|
|
@Test("Flipping the modifier mid-drag re-admits and re-lifts, every time")
|
|
func theFlipGoesBothWays() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store)
|
|
|
|
for modifiers in [none, option, none, option] {
|
|
session.resolveOperation(destinationRoot: store.rootURL, modifiers: modifiers)
|
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
|
#expect(hidden == (modifiers == option ? [] : [Self.card1]))
|
|
}
|
|
}
|
|
|
|
// MARK: The two things the rule does not reach
|
|
|
|
/// The trash column's rows never lift, whatever the operation: they render in the column rather
|
|
/// than in a lane's masonry, so there is no resting layout of a lane's for them to leave. They
|
|
/// dim in place instead (`CardFaceView`, `TrashLaneRowView`).
|
|
@Test("A trash-container session hides nothing, under either operation")
|
|
func trashSessionsAreUnchanged() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash)
|
|
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
|
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
|
}
|
|
|
|
@Test("Only the source board hides anything, whatever the operation")
|
|
func onlyTheSourceBoardHides() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store)
|
|
|
|
session.resolveOperation(destinationRoot: elsewhere, modifiers: command)
|
|
#expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
|
}
|
|
|
|
/// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to
|
|
/// `.copy`, so the strip's zones under the cursor never see a re-admitted lane and `moveLanes`
|
|
/// keeps the index space it always had. A **cross-board** lane copy does re-admit — into the
|
|
/// source strip, while the cursor is over the foreign board.
|
|
@Test("A within-board lane drag never re-admits; a cross-board lane copy does")
|
|
func laneSessionsFollowTheCarveOut() 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)],
|
|
units: [1],
|
|
source: store
|
|
)
|
|
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.lane1])
|
|
|
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
|
}
|
|
|
|
// MARK: The hold freezes it
|
|
|
|
/// A release settles the arrangement, and the operation is part of the arrangement now: the
|
|
/// write that went out named one, and a stray `dropUpdated` sampling a modifier the user has
|
|
/// already let go of must not redraw the board against the other one.
|
|
@Test("A settled copy keeps its originals on screen, whatever the modifiers do next")
|
|
func aSettledCopyStaysReAdmitted() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store)
|
|
|
|
session.resolveOperation(destinationRoot: store.rootURL, modifiers: option)
|
|
session.commit(into: store)
|
|
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .copy)
|
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy)
|
|
#expect(session.operation == .copy)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
|
}
|
|
|
|
@Test("A settled move keeps its originals lifted, whatever the modifiers do next")
|
|
func aSettledMoveStaysLifted() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let session = cardSession(store)
|
|
|
|
session.resolveOperation(destinationRoot: store.rootURL, modifiers: none)
|
|
session.commit(into: store)
|
|
|
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
|
|
#expect(session.operation == .move)
|
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
|
}
|
|
}
|