Files
lanework/KanbanTests/DragSessionTests.swift
T
rzen 8f116934b4 Implement Finder file drops
Files from Finder land on the board per 04-interactions.md § Drag & drop:

- Dropped on a card, they copy into its attachments/ (any type, multi-file),
  the face highlighting while hovered; the hovered card is resolved by
  hit-testing the same analytic masonry frames the card zones are built
  from, so attach-beats-create adds no drop region and cannot drift from
  the dispatch.
- Dropped on lane empty space, one card per file — filename minus extension
  as the title (a blank stem omits the key), fresh GUID, rank at the drop
  position through the ordinary insertion machinery, the file attached —
  all in one bracket; a failed import removes the just-minted card, so
  creating-then-abandoning never leaves an empty card behind.
- Every board drop surface now declares .fileURL beside the two board
  types (the single-target-dispatch rule); tombstoned surfaces are inert;
  file sessions ride a distinct session mode with their own watchdog and
  no hysteresis, leaving the board-drag machinery untouched.
- Also: four empty fixture directories pinned with .keep files so git
  preserves them, and a test-only visibility fix in DragSessionTests.

811 unit tests (12 new).

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

185 lines
8.8 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))
}
}