diff --git a/KanbanTests/PointerLatencyTests.swift b/KanbanTests/PointerLatencyTests.swift index b03aaf2..e0a8d2b 100644 --- a/KanbanTests/PointerLatencyTests.swift +++ b/KanbanTests/PointerLatencyTests.swift @@ -15,15 +15,30 @@ import Testing /// left click. After: both a handful of milliseconds. /// /// The fix moved the empty-space surfaces to a background layer behind the masonry (cards no -/// longer share a gesture path with any lane recogniser), replaced the two-tap recogniser with one -/// `.onTapGesture` branching on `PointerClick.count`, and gave the layer an empty-provider -/// `.onDrag` — without a drag source, macOS holds a subtree's primary clicks pending multi-click -/// disambiguation (the lone-click pin below is the tripwire for that regressing). The behavioral -/// halves are pinned alongside the latency: -/// - a card double-click opens the card window and creates **no** placeholder (the layer is not -/// the card's ancestor, so its create can never fire for a card's clicks), -/// - an empty-space double-click opens the placeholder **and keeps the lane selected** — the -/// pair's second click is the create alone, never also the toggle (`PointerClick.count`). +/// longer share a gesture path with any lane recogniser) and replaced the two-tap recogniser with +/// one `.onTapGesture` branching on `PointerClick.count`. The layer carries **no drag source** — +/// an `.onDrag` there, even empty-provider, claims drags outright and kills the rubber band +/// (measured on real events, 2026-08-07; see `LaneView`). +/// +/// **That choice puts the lane's empty space beyond this harness's reach.** In this suite's +/// sterile queue macOS holds a dragless surface's primary clicks pending multi-click +/// disambiguation and never releases them — successive synthetic clicks do not release a held +/// predecessor the way real ones do, and injecting synthetic micro-motion deadlocks AppKit's +/// mouse-tracking loop (it blocks on `nextEvent` for hardware that isn't there). Worse, every +/// held-and-orphaned click poisons the app-global held-event machinery for the *rest of the +/// process* — a prior revision of this suite probed empty space first and watched the right-click +/// pin fail downstream. So empty-space clicks are deliberately absent here. Their truth on a real +/// event stream is established and re-checkable with the CGEvent driver (2026-08-07 session: +/// taps ~1–3 ms, `clickCount=2` reaches the create branch, the marquee sweeps end to end) — real +/// streams have nothing to disambiguate on a count-1 tap. +/// +/// What this suite pins is the original defect's surface, which is also the one it can see: +/// - a card click selects **instantly** — the ~475 ms tripwire; a container-level multi-click +/// recogniser regressing would re-hold every card click (card faces are drag-sourced, so +/// their clicks are hold-free in both stream kinds), +/// - a card double-click opens the card window and creates **no** placeholder (the empty-space +/// layer is not the card's ancestor, so its create can never fire for a card's clicks), +/// - a right-click on the heels of a left click reaches its menu instantly. /// /// Events go through `NSApp.postEvent` and are drained via `NSApp.nextEvent` rather than /// `window.sendEvent`, because `PointerClick` reads `NSApp.currentEvent` — which only the real @@ -204,36 +219,6 @@ private func waitFor(_ timeout: TimeInterval, condition: () -> Bool) -> Double? return nil } -/// `waitFor`, with the pointer resting live near `p`: posts a `.mouseMoved` with 1 pt of jitter -/// every ~30 ms, the micro-motion a real pointer always emits. AppKit's held-event machinery -/// resolves pending click disambiguation off the *timestamps of subsequent events* — a perfectly -/// sterile queue can defer a held click forever, which no real event stream ever does. -@MainActor -private func waitForWithMotion( - at p: NSPoint, in window: NSWindow, timeout: TimeInterval, condition: () -> Bool -) -> Double? { - let t0 = CACurrentMediaTime() - var lastMove = t0 - var jitter = false - while CACurrentMediaTime() - t0 < timeout { - pump(0.004) - if condition() { return (CACurrentMediaTime() - t0) * 1000 } - if CACurrentMediaTime() - lastMove > 0.03 { - lastMove = CACurrentMediaTime() - jitter.toggle() - let moved = NSPoint(x: p.x + (jitter ? 1 : 0), y: p.y) - let event = NSEvent.mouseEvent( - with: .mouseMoved, location: moved, modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, context: nil, - eventNumber: Int.random(in: 1...999_999), clickCount: 0, pressure: 0 - )! - NSApp.postEvent(event, atStart: false) - } - } - return nil -} - /// The menu-tracking observer's mailbox — statics because the notification closure is @Sendable. private enum MenuProbe { nonisolated(unsafe) static var beganAt: CFTimeInterval? @@ -245,26 +230,6 @@ private enum MenuProbe { /// investigation probe and pinned by `#require` on the selection it produces. private let cardPoint = NSPoint(x: 90, y: 1000 - 90) -/// Finds a point whose click selects lane 0 itself — its empty space. Probed rather than -/// hard-coded, so the pin does not depend on how far the lane's click surface happens to extend -/// below its cards on any given layout. -@MainActor -private func findEmptySpacePoint(on board: HostedBoard) -> NSPoint? { - let laneID = ItemID(rawValue: laneName(0)) - for x in stride(from: 60, through: 240, by: 60) { - for yTop in stride(from: 500, through: 120, by: -60) { - let p = NSPoint(x: CGFloat(x), y: 1000 - CGFloat(yTop)) - click(at: p, in: board.window, clicks: 1) - _ = waitFor(0.4) { !board.store.selection.isEmpty } - let hit = board.store.selection.ids == [laneID] - board.store.clearSelection() - pump(0.2) - if hit { return p } - } - } - return nil -} - // MARK: - The pins @MainActor @@ -323,51 +288,6 @@ struct PointerLatencyTests { #expect(store.selection.ids.contains(target)) } - @Test("An empty-space double-click opens the placeholder and keeps the lane selected") - func emptySpaceDoubleClickCreatesThePlaceholder() throws { - let fixture = try makeFixture() - defer { fixture.tearDown() } - let board = try host(fixture) - let store = board.store - let emptySpacePoint = try #require(findEmptySpacePoint(on: board), - "no probe point selected lane 0's empty space") - - // A lone empty-space click first: the lane's own selection latency, the defect's original - // surface. Measured ~475 ms before the fix — and unbounded on a surface that carries a - // two-tap recogniser without a drag source, which is why the layer branches one tap on - // `PointerClick.count` instead. The pointer rests live near the click, as a real one does. - post(.leftMouseDown, at: emptySpacePoint, in: board.window, clicks: 1) - pump(0.02) - post(.leftMouseUp, at: emptySpacePoint, in: board.window, clicks: 1) - let lone = try #require( - waitForWithMotion(at: emptySpacePoint, in: board.window, timeout: 2.0) { !store.selection.isEmpty }, - "a lone empty-space click never selected the lane" - ) - print(String(format: "── lone empty-space click → lane selected: %.0f ms", lone)) - #expect(lone < 250, "empty-space click → selection took \(Int(lone)) ms") - #expect(store.selection.ids == [ItemID(rawValue: laneName(0))]) - store.clearSelection() - pump(0.8) - - // The pair: first click selects, second creates — and the first click's selection - // survives, because the second click is the create alone, never also the toggle. - click(at: emptySpacePoint, in: board.window, clicks: 1) - let firstClick = try #require(waitFor(0.5) { !store.selection.isEmpty }, - "the pair's first click should select the lane") - print(String(format: "── pair's first click → lane selected: %.0f ms", firstClick)) - click(at: emptySpacePoint, in: board.window, clicks: 2) - _ = waitFor(1.0) { store.transient.newCardPlaceholder != nil } - board.settle(turns: 2) - - let placeholder = try #require(store.transient.newCardPlaceholder, - "the empty-space double-click should open the placeholder") - #expect(placeholder.laneID == ItemID(rawValue: laneName(0))) - // The first click of the pair selected the lane; the second is the create alone - // (`PointerClick.count` branches it away from the toggle), so the selection survives. - #expect(store.selection.ids == [ItemID(rawValue: laneName(0))], - "the pair's first click's selection should survive, selection \(store.selection.ids)") - } - @Test("A right-click on the heels of a left click reaches its menu without the wait") func rightClickMenuAfterAClick() throws { let fixture = try makeFixture()