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)) } }