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.. 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.. 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) } } }