Files
lanework/KanbanTests/DragSessionTests.swift
T
rzen 90cf82d740 Implement drag & drop with the locality model
The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.

- Card faces, lane headers, and trash rows drag as NSItemProvider sessions
  (two exported UTTypes, JSON payload in flatten order, plain-text titles as
  the secondary representation) — replacing m4's custom lane-reorder gesture
  and trash drag-out wholesale; the app-wide DragSession carries the members,
  the frozen dragged sizes, the live proposal, and the effective operation.
- Three drop delegates (lane masonry, strip, window fallback), each accepting
  both types and routing internally per the single-target-dispatch rule; the
  cursor is the physical mouse converted to strip space; proposals come from
  DropSlotMath with hysteresis threaded through, and the lane-strip proposal
  clamps in front of the shown trash.
- Locality picks the default — move within a board, copy across, the badge
  tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘
  forces move; trash rows restore within their board (positional), copy out
  across boards by default, ⌘ forcing the true restore-move.
- N contiguous shadows with reflow keyed on the proposal; the
  committed-overlay hold renders the dropped arrangement until the reload
  echo lands (1.5 s dissolution deadline for refused writes); the
  re-grounding trio: geometry re-derives per render, proposals re-validate
  by liveness at release, an emptied drag cancels itself.
- Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per
  step), the mouse-up-gated late-event cleanup, and the polling watchdog —
  the pathfinder's lifecycle traps, ported.
- Store: moveLanes and multi-card restoreByDrag join the one-bracket drop
  commits.

784 unit tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 20:58:26 -04:00

182 lines
8.6 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 {
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 none: NSEvent.ModifierFlags = []
private static let option: NSEvent.ModifierFlags = [.option]
private static 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))
}
}