The latency suite stops probing what its harness cannot hold — empty-space pins retire to the CGEvent driver
The dragless empty-space layer's clicks are held forever in a sterile synthetic queue (and injected micro-motion deadlocks AppKit's tracking loop), so the empty-space pins leave the suite; their truth is established on real event streams (2026-08-07). What remains pinned is the original defect's surface: instant card selection, double-click opens without a placeholder, right-click reaching its menu. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -15,15 +15,30 @@ import Testing
|
|||||||
/// left click. After: both a handful of milliseconds.
|
/// left click. After: both a handful of milliseconds.
|
||||||
///
|
///
|
||||||
/// The fix moved the empty-space surfaces to a background layer behind the masonry (cards no
|
/// 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
|
/// longer share a gesture path with any lane recogniser) and replaced the two-tap recogniser with
|
||||||
/// `.onTapGesture` branching on `PointerClick.count`, and gave the layer an empty-provider
|
/// one `.onTapGesture` branching on `PointerClick.count`. The layer carries **no drag source** —
|
||||||
/// `.onDrag` — without a drag source, macOS holds a subtree's primary clicks pending multi-click
|
/// an `.onDrag` there, even empty-provider, claims drags outright and kills the rubber band
|
||||||
/// disambiguation (the lone-click pin below is the tripwire for that regressing). The behavioral
|
/// (measured on real events, 2026-08-07; see `LaneView`).
|
||||||
/// halves are pinned alongside the latency:
|
///
|
||||||
/// - a card double-click opens the card window and creates **no** placeholder (the layer is not
|
/// **That choice puts the lane's empty space beyond this harness's reach.** In this suite's
|
||||||
/// the card's ancestor, so its create can never fire for a card's clicks),
|
/// sterile queue macOS holds a dragless surface's primary clicks pending multi-click
|
||||||
/// - an empty-space double-click opens the placeholder **and keeps the lane selected** — the
|
/// disambiguation and never releases them — successive synthetic clicks do not release a held
|
||||||
/// pair's second click is the create alone, never also the toggle (`PointerClick.count`).
|
/// 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
|
/// Events go through `NSApp.postEvent` and are drained via `NSApp.nextEvent` rather than
|
||||||
/// `window.sendEvent`, because `PointerClick` reads `NSApp.currentEvent` — which only the real
|
/// `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
|
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.
|
/// The menu-tracking observer's mailbox — statics because the notification closure is @Sendable.
|
||||||
private enum MenuProbe {
|
private enum MenuProbe {
|
||||||
nonisolated(unsafe) static var beganAt: CFTimeInterval?
|
nonisolated(unsafe) static var beganAt: CFTimeInterval?
|
||||||
@@ -245,26 +230,6 @@ private enum MenuProbe {
|
|||||||
/// investigation probe and pinned by `#require` on the selection it produces.
|
/// investigation probe and pinned by `#require` on the selection it produces.
|
||||||
private let cardPoint = NSPoint(x: 90, y: 1000 - 90)
|
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
|
// MARK: - The pins
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -323,51 +288,6 @@ struct PointerLatencyTests {
|
|||||||
#expect(store.selection.ids.contains(target))
|
#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")
|
@Test("A right-click on the heels of a left click reaches its menu without the wait")
|
||||||
func rightClickMenuAfterAClick() throws {
|
func rightClickMenuAfterAClick() throws {
|
||||||
let fixture = try makeFixture()
|
let fixture = try makeFixture()
|
||||||
|
|||||||
Reference in New Issue
Block a user