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