Files
lanework/KanbanTests/PointerLatencyTests.swift
T
rzen 6d1872ad8b The harness waits for the masonry it aims at — sixty settle turns before the first click
Under parallel-build load the six-turn settle let the first click land before the masonry's measurement passes did, on the empty-space layer instead of a card — which the dragless layer holds forever in a sterile queue, reading as a dead click. Bisect-verified environmental (same failure at every commit back to the suite's birth); with the settle, click → selection in 41 ms.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 10:25:31 -04:00

349 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Foundation
import SwiftUI
import Testing
@testable import Kanban
/// **Regression pins for the 2026-08-06 click-latency fix**, measured on a real hosted `BoardView`
/// with synthetic pointer events.
///
/// The defect: `LaneView`'s empty-space double-click was a second `.onTapGesture(count: 2)` stacked
/// over the single tap, and a sequential two-tap recogniser holds every single click on that
/// container — its own empty space and every card face it wraps — hostage to the system
/// double-click interval (500 ms here) while it disambiguates. Measured before the fix: ~475 ms
/// from click to selection, and ~464 ms from right-click to menu when the right-click followed a
/// 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) 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 ~13 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
/// dequeue path populates — and because the dequeue path is the one real clicks take through the
/// hold-and-release machinery this suite exists to pin.
// MARK: - Fixture
private let laneCount = 6
private let cardsPerLane = 30
/// Lane 0 stays short so it has visible empty space to double-click.
private let shortLaneCards = 3
private func laneName(_ lane: Int) -> String {
String(format: "1%07d-1111-4111-8111-111111111111", lane)
}
private func cardName(_ lane: Int, _ card: Int) -> String {
String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
}
@MainActor
private func makeFixture() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.board(title: "Latency Board")
for lane in 0..<laneCount {
try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
let cards = lane == 0 ? shortLaneCards : cardsPerLane
for card in 0..<cards {
try fixture.card(
cardName(lane, card),
in: laneName(lane),
order: "\((card + 1) * 1024)",
title: "Card \(lane)-\(card)",
body: "Body text for card \(lane)-\(card)."
)
}
}
return fixture
}
// MARK: - Hosting (BoardRenderPerformanceTests' harness, with an openCard capture)
private struct ZoomedBoard: View {
let store: BoardStore
let window: @MainActor () -> NSWindow?
let confirmations: TrashConfirmations
let openCard: @MainActor (ItemID) -> Void
let search: BoardSearchPresentation
@Environment(AppModel.self) private var appModel
var body: some View {
BoardView(
store: store,
window: window,
confirmations: confirmations,
openCard: openCard,
search: search
)
.environment(\.boardZoom, appModel.zoom.context)
}
}
@MainActor
private final class HostedBoard {
let store: BoardStore
let appModel: AppModel
let window: NSWindow
let view: NSView
private(set) var openedCards: [ItemID] = []
private let scratch: URL
private let preferencesDomain: String
init(store: BoardStore, scratch: URL) {
self.store = store
self.scratch = scratch
preferencesDomain = "dev.rzen.indie.Kanban.pointer-latency.\(UUID().uuidString)"
appModel = AppModel(
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
preferences: UserDefaults(suiteName: preferencesDomain)!
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
styleMask: [.titled], backing: .buffered, defer: false
)
self.window = window
var recordOpen: (ItemID) -> Void = { _ in }
let root = ZoomedBoard(
store: store,
window: { [weak window] in window },
confirmations: TrashConfirmations(),
openCard: { recordOpen($0) },
search: BoardSearchPresentation()
)
.environment(appModel)
let hosting = NSHostingView(rootView: root)
hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
view = hosting
window.contentView = hosting
window.orderBack(nil)
window.makeKey()
recordOpen = { [weak self] id in
MainActor.assumeIsolated { self?.openedCards.append(id) }
}
// A generous settle, and not politeness: the masonry places cards only after measurement
// round-trips, and until those land a click at `cardPoint` hits the *empty-space layer*
// instead of a card — which the dragless layer holds forever in this sterile queue,
// poisoning the process's held-event machinery (see the suite note above). Six turns
// sufficed on an idle machine; under parallel-build load it reproducibly did not
// (2026-08-08). The turns are cheap; the misfire is not.
settle(turns: 60)
}
deinit {
window.orderOut(nil)
window.contentView = nil
try? FileManager.default.removeItem(at: scratch)
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
}
func settle(turns: Int = 6) {
for _ in 0..<turns {
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
view.layoutSubtreeIfNeeded()
window.displayIfNeeded()
}
}
}
@MainActor
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
let scratch = FileManager.default.temporaryDirectory
.appendingPathComponent("PointerLatency-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
}
// MARK: - Event synthesis
/// Posts one mouse event into the application's queue — the queue, not `window.sendEvent`, so the
/// dequeue below stamps it as `NSApp.currentEvent` the way a real click is.
@MainActor
private func post(_ type: NSEvent.EventType, at p: NSPoint, in window: NSWindow, clicks: Int) {
let event = NSEvent.mouseEvent(
with: type, location: p, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil,
eventNumber: Int.random(in: 1...999_999), clickCount: clicks, pressure: 1
)!
NSApp.postEvent(event, atStart: false)
}
/// Drains the queue through the real dequeue-and-dispatch path, then gives SwiftUI a turn.
@MainActor
private func pump(_ seconds: TimeInterval) {
let deadline = Date().addingTimeInterval(seconds)
repeat {
while let event = NSApp.nextEvent(matching: .any, until: .distantPast, inMode: .default, dequeue: true) {
NSApp.sendEvent(event)
}
RunLoop.main.run(until: min(Date().addingTimeInterval(0.004), deadline))
} while Date() < deadline
}
/// One full click — down then up, drained after each half.
@MainActor
private func click(at p: NSPoint, in window: NSWindow, clicks: Int = 1) {
post(.leftMouseDown, at: p, in: window, clicks: clicks)
pump(0.02)
post(.leftMouseUp, at: p, in: window, clicks: clicks)
pump(0.02)
}
/// Pumps until `condition` holds; returns elapsed ms, or nil on timeout.
@MainActor
private func waitFor(_ timeout: TimeInterval, condition: () -> Bool) -> Double? {
let t0 = CACurrentMediaTime()
while CACurrentMediaTime() - t0 < timeout {
pump(0.004)
if condition() { return (CACurrentMediaTime() - t0) * 1000 }
}
return nil
}
/// The menu-tracking observer's mailbox — statics because the notification closure is @Sendable.
private enum MenuProbe {
nonisolated(unsafe) static var beganAt: CFTimeInterval?
}
// MARK: - Probe points
/// Lane 0's first card sits near the strip's top-leading corner; found empirically by the
/// investigation probe and pinned by `#require` on the selection it produces.
private let cardPoint = NSPoint(x: 90, y: 1000 - 90)
// MARK: - The pins
@MainActor
@Suite("Pointer latency on a hosted board", .serialized)
struct PointerLatencyTests {
@Test("A click on a card selects it without the double-click wait")
func cardClickSelectsWithoutTheDoubleClickWait() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let store = board.store
post(.leftMouseDown, at: cardPoint, in: board.window, clicks: 1)
pump(0.02)
post(.leftMouseUp, at: cardPoint, in: board.window, clicks: 1)
let latency = try #require(
waitFor(2.0) { !store.selection.isEmpty },
"the click never selected anything — did the layout move under the probe point?"
)
// Which card the point lands on is the masonry's business (column-major, two columns at
// standard width) — what matters is that it is *a card*, instantly. Fixture card ids all
// start with "2", lanes with "1".
let selected = try #require(store.selection.ids.first)
#expect(selected.rawValue.hasPrefix("2"),
"the probe point should land on a card, selected \(store.selection.ids)")
// The defect measured ~475 ms here — the system double-click interval leaking into every
// single click. The bound is generous headroom over the healthy ~5 ms, far under the
// interval it must never re-approach.
#expect(latency < 250, "click → selection took \(Int(latency)) ms")
print(String(format: "── click → selection: %.0f ms", latency))
}
@Test("A card double-click opens the card window and creates no placeholder")
func cardDoubleClickOpensTheWindow() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let store = board.store
click(at: cardPoint, in: board.window, clicks: 1)
_ = waitFor(1.0) { !store.selection.isEmpty }
let target = try #require(store.selection.ids.first,
"the pair's first click should select the card under the point")
click(at: cardPoint, in: board.window, clicks: 2)
_ = waitFor(1.0) { !board.openedCards.isEmpty }
board.settle(turns: 2)
#expect(board.openedCards == [target],
"the double-click should open exactly the clicked card, opened \(board.openedCards)")
// The empty-space layer is a background sibling of the masonry, not the card's ancestor —
// structurally, a card's clicks can never reach its create. This pins that structure.
#expect(store.transient.newCardPlaceholder == nil,
"a double-click on a card must not open the lane's placeholder")
#expect(store.selection.ids.contains(target))
}
@Test("A right-click on the heels of a left click reaches its menu without the wait")
func rightClickMenuAfterAClick() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let store = board.store
let observer = NotificationCenter.default.addObserver(
forName: NSMenu.didBeginTrackingNotification, object: nil, queue: nil
) { note in
if MenuProbe.beganAt == nil { MenuProbe.beganAt = CACurrentMediaTime() }
guard let menu = note.object as? NSMenu else { return }
nonisolated(unsafe) let pending = menu
// .common fires during menu tracking, which is what ends it — without this the
// dispatch below never returns.
let timer = Timer(timeInterval: 0.1, repeats: false) { _ in
pending.cancelTrackingWithoutAnimation()
}
RunLoop.main.add(timer, forMode: .common)
}
defer { NotificationCenter.default.removeObserver(observer) }
func rightClick(_ label: String) -> Double? {
MenuProbe.beganAt = nil
let armed = CACurrentMediaTime()
post(.rightMouseDown, at: cardPoint, in: board.window, clicks: 1)
pump(0.02)
post(.rightMouseUp, at: cardPoint, in: board.window, clicks: 1)
_ = waitFor(2.0) { MenuProbe.beganAt != nil }
let latency = MenuProbe.beganAt.map { ($0 - armed) * 1000 }
print(latency.map { String(format: "── right-click → menu (%@): %.0f ms", label, $0) }
?? "── right-click → menu (\(label)): NEVER")
return latency
}
// The first menu open in a process pays a one-time AppKit warmup (~350 ms) — spent here,
// unasserted, so the pinned figures below measure the steady state users live in.
_ = rightClick("cold, first menu in the process")
pump(1.5)
for gap in [0.1, 0.4, 0.8] {
click(at: cardPoint, in: board.window, clicks: 1)
_ = waitFor(1.0) { !store.selection.isEmpty }
pump(gap)
let label = String(format: "%.1f s after a left click", gap)
let latency = try #require(rightClick(label), "the context menu never began tracking")
// ~464 ms before the fix: pending click disambiguation deferred the menu too.
#expect(latency < 250, "right-click → menu (\(label)) took \(Int(latency)) ms")
store.clearSelection()
pump(1.2)
}
}
}