Modifiers were sampled only inside resolveOperation, reached from dropUpdated and the commit — and drop callbacks arrive only while the mouse moves, so ⌥ pressed against a still pointer changed nothing until the next twitch. DragSession now arms a local .flagsChanged watch for exactly the session's lifetime (begin arms, end stops, the watchdog guarantees end; ⇧/⌃/caps don't count, a settled hold swallows) and publishes modifierGeneration; the three retargets record which window's surface resolved the proposal (RetargetOrigin — weak registry identity, never board root, so cross-board and two-windows- one-board both answer correctly), and the hovered window replays that same retarget with the operation re-resolved FIRST, since the index space is a function of it. The monitor is injectable — the real one needs a live event stream no test bundle has. Drag-perf card d491e7d3. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
562 lines
23 KiB
Swift
562 lines
23 KiB
Swift
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])
|
|
}
|
|
}
|