Files
lanework/KanbanTests/PointerLatencyTests.swift
T
rzen 8aefaf23ce The empty provider was never load-bearing — the dragless layer frees the rubber band
Measured on real events 2026-08-07, correcting the 2026-08-06 hosted
finding: a bare count-1 tap on LaneView's empty-space layer fires in
~1-3 ms with no drag source at all — the hold that made the empty
.onDrag look necessary was the sterile NSApp.postEvent stream
over-disambiguating. And the provider was actively harmful: even an
empty drag source claims the mouse-drag at threshold, starving the
marquee's simultaneous DragGesture after one sample — the band froze
and the mouseUp never arrived. The layer goes dragless; drags from
empty space belong wholly to MarqueeControl. PointerClick's and the
layer's comments retell the corrected story. Alongside: openCard is
typed @MainActor throughout, which makes the closure Sendable and
lets CardFaceRole carry it under CardFaceView's nonisolated ==.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 12:55:54 -04:00

423 lines
19 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), 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`).
///
/// 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) }
}
settle()
}
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
}
/// `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?
}
// 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)
/// 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
@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("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()
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)
}
}
}