import AppKit import Foundation import Testing @testable import Kanban /// **The stationary modifier flip** (04-interactions.md ▸ Drag and drop: "⌥ always forces copy and /// ⌘ always forces move … the badge tracks the effective operation live"). /// /// The effective operation is sampled inside `DragSession.resolveOperation`, which is reached from /// `dropUpdated` and from the commit — and `DropDelegate` callbacks arrive only while the mouse /// **moves**. So a modifier pressed against a perfectly still pointer used to change nothing until /// the next twitch: the source board's originals stayed lifted (`hiddenMembers` follows the /// operation since the copy-redraw ruling), the trash column kept refusing a copy it would now take, /// and the shadows stood in an index space the release no longer counted in. /// /// The fix is a `.flagsChanged` watch whose lifetime is the drag's, and whose only output is a /// counter; the board window under the cursor turns that counter back into **the one shared /// retarget** (`BoardDropContext.retargetAfterModifierFlip`) — the third client of the seam /// DRAG-REORDER.md § Edge autoscroll opened for the autoscroll driver. /// /// Two halves, tested at the two seams they live at: /// /// - the **watch's lifetime** and what counts as a flip, on `DragSession`, through a scripted /// source — the shipping one is a local `NSEvent` monitor, which needs a live application event /// stream a test bundle cannot feed (`DragAutoScrollDriverTests`' scripted tick source exactly); /// - the **funnel**, on `BoardDropContext`, through the modifier seam `resolveOperation` already /// established: the flags are a defaulted parameter, so the whole re-proposal is checkable /// without a keyboard. /// /// What is *not* here, because no unit can hold it: whether the system's own drag badge tracks /// modifiers without our help (`NSDraggingSource` ANDs modifier-driven operations into the mask /// unless `ignoreModifierKeysWhileDragging`), and whether a local monitor is delivered inside /// AppKit's drag-tracking loop at all. Both are live-verify items; the source is a seam precisely so /// the second one has somewhere to be answered. // MARK: - The scripted source /// A stand-in for `LocalModifierFlipSource` whose flags the test presses, and which records the one /// thing the real source must do when a drag ends: it removes the monitor. @MainActor private final class ScriptedFlips: ModifierFlipSource { /// How many monitors this source has installed, and how many have been retired — the pair every /// leak claim below is made of. private(set) var installs = 0 private(set) var removals = 0 private var handler: (@MainActor (NSEvent.ModifierFlags) -> Void)? private var current: Watch? /// Whether a monitor is installed right now. var isWatching: Bool { handler != nil } func watch( _ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void ) -> any ModifierFlipWatch { installs += 1 handler = onFlip let watch = Watch(source: self) current = watch return watch } /// A `.flagsChanged` arriving with this flag state. The event reports the **whole** set rather /// than a delta, which is what the session's own comparison assumes. func press(_ flags: NSEvent.ModifierFlags) { handler?(flags) } fileprivate func retire(_ watch: Watch) { guard current === watch else { return } removals += 1 handler = nil current = nil } /// Stopping twice is not two removals — the session's teardown calls `stop()` on every path /// without asking whether a watch is installed. @MainActor fileprivate final class Watch: ModifierFlipWatch { private unowned let source: ScriptedFlips private var stopped = false init(source: ScriptedFlips) { self.source = source } func stop() { guard !stopped else { return } stopped = true source.retire(self) } } } // MARK: - Fixtures /// One lane with one card — enough for a session to pick up, a lane to propose into, and a trash /// column to refuse. @MainActor 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")) return fixture } /// A drop context with no window, which is exactly what the flip path can be asked about: every /// cursor-dependent retarget holds (`globalCursor` answers `nil`), so what is left is the operation, /// the origin dispatch and the trash's modifier-sensitive refusal — the three things a flip decides. @MainActor private func makeDrops( store: BoardStore, session: DragSession, registry: LaneDropRegistry ) -> BoardDropContext { BoardDropContext( store: store, session: session, registry: registry, gap: 12, window: { nil }, stripFrame: { .zero }, standard: { 260 } ) } @MainActor private func pickUpCard(_ session: DragSession, from store: BoardStore) { session.beginCards( [ItemID(rawValue: Ident.card1)], folders: [store.rootURL .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent(Ident.card1, isDirectory: true)], heights: [44], container: .board, source: store ) } // MARK: - The watch's lifetime /// **The monitor cannot outlive the drag**, and the reason it cannot is structural rather than a /// list of call sites: it is armed at `begin` and stopped at `end`, and `end` is where every way a /// drag can finish already funnels — a delegate's cancel, the button-up belt, the watchdog, the /// hold's hand-off, the hold's timeout. @MainActor @Suite("Modifier flips ▸ the watch's lifetime") struct ModifierFlipLifetimeTests { @Test("A drag arms the watch, and ending it retires the monitor") func armedAtBeginRetiredAtEnd() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) #expect(!flips.isWatching, "an idle session watches nothing") pickUpCard(session, from: store) #expect(flips.isWatching) #expect(flips.installs == 1) session.end() #expect(!flips.isWatching) #expect(flips.removals == 1, "the token must be handed back, or the block fires forever") } @Test("A second drag never leaves the first one's monitor installed") func aSecondBeginRetiresTheFirstWatch() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) pickUpCard(session, from: store) // `begin` without an intervening `end` — the lifecycle trap the watchdog exists for, where a // finished session's events arrive after the next drag has started. pickUpCard(session, from: store) #expect(flips.installs == 2) #expect(flips.removals == 1, "arming replaces the watch rather than stacking a second one") #expect(flips.isWatching) session.end() #expect(flips.removals == 2) } /// The path no delegate can see: a drag macOS never reports the end of. The watchdog polls the /// physical button and clears the session, and clearing the session is what retires the monitor. @Test("The watchdog's own teardown retires the monitor") func theWatchdogRetiresTheWatch() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) pickUpCard(session, from: store) #expect(flips.isWatching) // The button is not down in a test bundle, so the watchdog's poll plus its grace period is // the whole wait. Bounded, so a watchdog that never fires fails rather than hangs. var cleared = false for _ in 0..<200 where !cleared { try? await Task.sleep(for: .milliseconds(10)) cleared = !session.isActive } #expect(cleared, "the watchdog is the guaranteed termination path") #expect(!flips.isWatching) #expect(flips.removals == 1) } /// A Finder file session arms none of this object's own state, so it arms no watch either — and /// it needs none: a file drop is always a copy (`fileDropProposal`), whatever is held down. @Test("An external file session arms no watch") func fileSessionsAreNotWatched() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) session.proposeFile(FileDropTarget( boardRoot: store.rootKey, landing: .create(laneID: ItemID(rawValue: Ident.lane1), index: 0), fileCount: 1 )) #expect(!flips.isWatching) #expect(flips.installs == 0) session.proposeFile(nil) } } // MARK: - What counts as a flip /// The nudge itself: one counter, bumped only when something the *operation* is a function of /// actually moved (`DragLocality.operation` reads ⌥ and ⌘, and nothing else). @MainActor @Suite("Modifier flips ▸ the nudge") struct ModifierFlipNudgeTests { @Test("Pressing ⌥ and releasing it are two flips") func pressAndReleaseBothNudge() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) pickUpCard(session, from: store) let base = session.modifierGeneration flips.press([.option]) #expect(session.modifierGeneration == base + 1) flips.press([]) #expect(session.modifierGeneration == base + 2) } @Test("A flags event that moves neither ⌥ nor ⌘ is not a flip") func irrelevantFlagsAreIgnored() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) pickUpCard(session, from: store) let base = session.modifierGeneration // ⇧ has its own meaning in the click grammar and none at all in the drag's operation. flips.press([.shift]) flips.press([.control]) flips.press([.capsLock]) #expect(session.modifierGeneration == base) // And ⌥ *with* one of them along for the ride is still exactly one flip. flips.press([.shift, .option]) #expect(session.modifierGeneration == base + 1) flips.press([.control, .option]) #expect(session.modifierGeneration == base + 1, "⌥ never moved") } /// The freeze, at its earliest point: a settled release is past retargeting, so a ⌥ released /// between the drop and the echo must not even cost a board window a body pass. @Test("A flip under a committed hold is not a nudge") func theHoldSwallowsFlips() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) pickUpCard(session, from: store) session.propose(DropTarget( boardRoot: store.rootKey, container: .lane(ItemID(rawValue: Ident.lane1)), index: 0 )) session.commit(into: store) let base = session.modifierGeneration flips.press([.option]) flips.press([]) #expect(session.modifierGeneration == base) } @Test("A flip after the drag is over reaches nothing at all") func aFinishedDragHearsNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let flips = ScriptedFlips() let session = DragSession(flipSource: flips) pickUpCard(session, from: store) session.end() let base = session.modifierGeneration flips.press([.option]) #expect(session.modifierGeneration == base, "the monitor is gone; this is the belt") } } // MARK: - The funnel: a flip runs the one shared retarget /// **The third client of the shared retarget.** A flip resolves nothing of its own: it re-runs the /// retarget that resolved the standing proposal, at the surface that resolved it, so a keystroke and /// a mouse sample land in exactly the same place by construction. @MainActor @Suite("Modifier flips ▸ the shared retarget") struct ModifierFlipRetargetTests { private static let lane1 = ItemID(rawValue: Ident.lane1) private static let card1 = ItemID(rawValue: Ident.card1) // MARK: The address a flip replays at @Test("Each retarget leaves its own address behind, even when it goes on to hold") func everyRetargetRecordsItsSurface() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let registry = LaneDropRegistry() let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: registry) #expect(session.retargetOrigin == nil, "a fresh session has nowhere to replay") pickUpCard(session, from: store) drops.retargetCards(inLane: Self.lane1) #expect(session.retargetOrigin?.surface == .lane(Self.lane1)) #expect(session.retargetOrigin?.registry === registry) drops.retargetTrash(modifiers: []) #expect(session.retargetOrigin?.surface == .trash) // The lane level: the strip's own zones. session.beginLanes( [Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)], units: [1], source: store ) #expect(session.retargetOrigin == nil, "a new drag starts with no address") drops.retargetLanes() #expect(session.retargetOrigin?.surface == .strip) session.end() #expect(session.retargetOrigin == nil) } // MARK: The re-admission at home /// The confirmed half of the card, end to end: ⌥ pressed with the mouse perfectly still flips /// the operation to a copy, and a copy leaves its originals standing in the source board's /// resting layout (`hiddenMembers`) — which used to wait for the next mouse sample. @Test("A stationary ⌥ re-admits the originals at home") func aStationaryOptionRedrawsTheSource() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let registry = LaneDropRegistry() let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: registry) pickUpCard(session, from: store) drops.retargetCards(inLane: Self.lane1) #expect(session.operation == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) drops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.operation == .copy) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty, "a copy's originals stay exactly where they are") // And releasing ⌥ lifts them again, with the pointer still untouched. drops.retargetAfterModifierFlip(modifiers: []) #expect(session.operation == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } /// The one carve-out that outranks the modifier: a within-board lane drag is a reorder and ⌥ is /// simply ignored there (`DragLocality.operation`). The flip runs; the answer does not move. @Test("A within-board lane drag ignores the flip, exactly as it ignores the modifier") func laneDragsIgnoreTheFlip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let registry = LaneDropRegistry() let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: registry) session.beginLanes( [Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)], units: [1], source: store ) drops.retargetLanes() drops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.operation == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1]) } // MARK: The operation-dependent refusal /// **The trash refuses copies** (04-interactions.md ▸ The trash): "copying into the trash is not /// a thing". The gate is a function of the operation, so it is a function of the modifiers — and /// until now it was re-asked only when the mouse moved, which is why a ⌥ released over the /// column left it inert under a pointer sitting right on it. @Test("Releasing ⌥ over the trash proposes the delete, with the pointer perfectly still") func theTrashReadmitsTheDropOnRelease() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.isTrashVisible = true let registry = LaneDropRegistry() let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: registry) pickUpCard(session, from: store) // The cursor arrives over the column with ⌥ down: the copy is refused, and the column // declines to be a target rather than cancelling the drag. drops.retargetTrash(modifiers: [.option]) #expect(session.operation == .copy) #expect(session.trashProposal(onBoardRooted: store.rootKey) == nil) // ⌥ comes up. No mouse event follows — this is the whole point. drops.retargetAfterModifierFlip(modifiers: []) #expect(session.operation == .move) #expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex) } // MARK: Rule 2, on the flip's path too /// The `.lane` replay is the lane delegate's own pair — revalidate, then the masonry's zones — /// so a proposal whose lane vanished in a reload withdraws on a flip exactly as it would on the /// next mouse sample. @Test("A flip revalidates before it retargets: a vanished lane's proposal withdraws") func theFlipAppliesRuleTwo() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let registry = LaneDropRegistry() let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: registry) pickUpCard(session, from: store) drops.retargetCards(inLane: Self.lane1) // A proposal naming a lane this board does not have — the reload that deleted it landed // while the pointer sat still. session.propose(DropTarget( boardRoot: store.rootKey, container: .lane(ItemID(rawValue: Ident.lane2)), index: 0 )) drops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.proposal == nil, "deleted lanes are never drop targets") } // MARK: Who answers /// The origin names a `LaneDropRegistry`, which is one board **window**'s — so the window under /// the cursor answers a flip and no other does. A root comparison would have a second window on /// the same board retargeting against a cursor nowhere near its lanes; a cross-board drag would /// have the *source* board answering for the destination. @Test("Only the window that resolved the proposal answers the flip") func anotherWindowDoesNotAnswer() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = DragSession(flipSource: ScriptedFlips()) let hovered = LaneDropRegistry() let elsewhere = LaneDropRegistry() let hoveredDrops = makeDrops(store: store, session: session, registry: hovered) let otherDrops = makeDrops(store: store, session: session, registry: elsewhere) pickUpCard(session, from: store) hoveredDrops.retargetCards(inLane: Self.lane1) otherDrops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.operation == .move, "this window is not where the proposal lives") #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) hoveredDrops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.operation == .copy) } @Test("A flip with no address recorded resolves nothing") func noOriginNoAnswer() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: LaneDropRegistry()) pickUpCard(session, from: store) drops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.operation == .move, "no surface has resolved a proposal yet") } // MARK: The freeze /// The same freeze `propose` and `resolveOperation` wear, asked once more at the funnel: the /// write named an operation and the overlay draws *that* operation until the echo lands, so a ⌥ /// released between the drop and the reload must not re-lift originals the write is leaving in /// place. @Test("A settled release is past retargeting — a flip changes nothing") func theHoldFreezesTheFlip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.isTrashVisible = true let registry = LaneDropRegistry() let session = DragSession(flipSource: ScriptedFlips()) let drops = makeDrops(store: store, session: session, registry: registry) pickUpCard(session, from: store) drops.retargetTrash(modifiers: []) #expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex) session.commit(into: store) drops.retargetAfterModifierFlip(modifiers: [.option]) #expect(session.operation == .move, "the write named the move") #expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } }