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
349 lines
15 KiB
Swift
349 lines
15 KiB
Swift
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 ~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
|
||
/// 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)
|
||
}
|
||
}
|
||
}
|