mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
9e211624cb
A coordinate click on a list row or a button did nothing. It moved the pointer (the row lit up on hover), the events reached the window, the tool reported "Action completed" — and the control never activated. Clicking a text field worked. Clicking the same row twice, as one `click_count: 2`, worked. Measured on one real session against an Electron target: single clicks 1/7, double clicks 3/3. The cleanest pair is two clicks 2px apart on the same suggestion row, with the window active both times — the only variable was the repeat count. Two fields were wrong, both since the engine's first commit: **Movement claimed to be part of a click.** The leading `mouseMoved` carried `clickCount` = the repeat count (1 for a single click, 2 for a double) instead of 0, so the target was told "a pointer belonging to click N arrived" before any button went down. The June comment that introduced it explains the 1…N cadence of the press/release pairs and says nothing about why the move takes it — a field reused, not a decision. August then widened it: the same value started being passed to `NSEvent.mouseEvent(clickCount:)` as well, under a `max(1, …)` that made 0 unreachable. **A press and its release were not tied together.** Each event took a fresh `eventNumber`, and AppKit uses that number to read two events as one click. A text field focuses on the press alone, so it worked; anything needing a complete click did not. Both now follow the reference implementation, read out of its disassembly: its drag-movement events carry `clickCount = 0` while the surrounding down/up carry 1, and its press/release pairs share one event number (a drag's down and up share one, the dragged steps in between share another). Verified against the same task that failed: single click on the suggestion row lands first try, 7 tool calls and 55s where the failing run took 31 calls and 402s, no retries and no double click. The rule is now stated once in `mouseClickState(for:click:)` instead of spelled out per event, and nine tests cover it — there were none. `pressure` and `mouseEventClickState` appeared nowhere in the suite, and no test had ever called `clickPoint`, which is how these values survived from the first commit through several rounds of "fix the clicks".
195 lines
8.1 KiB
Swift
195 lines
8.1 KiB
Swift
import CoreGraphics
|
|
import XCTest
|
|
@testable import cc_haha_computer_use
|
|
|
|
/// Regression cover for the field that decides whether a synthesized pointer
|
|
/// event counts as part of a click.
|
|
///
|
|
/// Background: the leading move of a coordinate click used to carry the repeat
|
|
/// count (1 for a single click, 2 for a double) instead of 0, so the target was
|
|
/// told "a pointer belonging to click N arrived" before any button went down.
|
|
/// Measured against an Electron target, single clicks then registered as hover
|
|
/// and never activated the control, while double clicks worked — the second
|
|
/// press/release pair got through. Nothing in the suite covered these fields,
|
|
/// which is why the value survived from the engine's first commit.
|
|
@MainActor
|
|
final class MouseClickStateTests: XCTestCase {
|
|
private let movement: [CGEventType] = [
|
|
.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged,
|
|
]
|
|
private let transitions: [CGEventType] = [
|
|
.leftMouseDown, .leftMouseUp,
|
|
.rightMouseDown, .rightMouseUp,
|
|
.otherMouseDown, .otherMouseUp,
|
|
]
|
|
|
|
func testMovementNeverBelongsToAClick() {
|
|
for type in movement {
|
|
// Including the double-click case: it is the one that used to make
|
|
// the leading move claim clickState 2.
|
|
for click in 1...3 {
|
|
XCTAssertEqual(
|
|
AXAction.mouseClickState(for: type, click: click),
|
|
0,
|
|
"movement event \(type.rawValue) must report clickState 0, not the click number"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testButtonTransitionsCarryTheirClickNumber() {
|
|
for type in transitions {
|
|
for click in 1...3 {
|
|
XCTAssertEqual(
|
|
AXAction.mouseClickState(for: type, click: click),
|
|
click,
|
|
"press/release must carry the 1…N cadence a real multi-click reports"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testMovementClassification() {
|
|
for type in movement {
|
|
XCTAssertTrue(AXAction.isMovement(type), "\(type.rawValue) is pointer transport")
|
|
}
|
|
for type in transitions {
|
|
XCTAssertFalse(AXAction.isMovement(type), "\(type.rawValue) is a button transition")
|
|
}
|
|
// Scroll is neither, and must not be mistaken for movement: it has no
|
|
// click cadence but is also not a pointer transport event we synthesize
|
|
// through this path.
|
|
XCTAssertFalse(AXAction.isMovement(.scrollWheel))
|
|
}
|
|
|
|
/// A zero must survive all the way onto the NSEvent the CGEvent field is
|
|
/// attached to. `AXAction.makeMouse` used to pass `max(1, clickState)` here,
|
|
/// which re-raised movement to "click 1" — the CGEvent field then said 0
|
|
/// while the event carrying it said 1.
|
|
func testWindowBoundEventPreservesAZeroClickCount() throws {
|
|
let event = try XCTUnwrap(
|
|
WindowTargetedEvent.makeMouseEvent(
|
|
type: .mouseMoved,
|
|
nsType: .mouseMoved,
|
|
point: CGPoint(x: 10, y: 20),
|
|
button: .left,
|
|
clickCount: 0,
|
|
windowID: 4321,
|
|
windowBounds: CGRect(x: 0, y: 0, width: 100, height: 100)
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
event.getIntegerValueField(.mouseEventClickState),
|
|
0,
|
|
"a movement event must not be reported as belonging to a click"
|
|
)
|
|
}
|
|
|
|
func testWindowBoundEventKeepsRealClickCounts() throws {
|
|
for click in 1...3 {
|
|
let event = try XCTUnwrap(
|
|
WindowTargetedEvent.makeMouseEvent(
|
|
type: .leftMouseDown,
|
|
nsType: .leftMouseDown,
|
|
point: CGPoint(x: 10, y: 20),
|
|
button: .left,
|
|
clickCount: click,
|
|
windowID: 4321,
|
|
windowBounds: CGRect(x: 0, y: 0, width: 100, height: 100)
|
|
)
|
|
)
|
|
XCTAssertEqual(event.getIntegerValueField(.mouseEventClickState), Int64(click))
|
|
}
|
|
}
|
|
|
|
/// A press and the release that ends it must be tied together by a shared
|
|
/// eventNumber — that pairing is how AppKit reads two events as one click.
|
|
/// Each event used to take a fresh number, so the target got a press and a
|
|
/// release it had no reason to associate. Measured symptom: a text field,
|
|
/// which focuses on the press alone, accepted our clicks, while list rows
|
|
/// and buttons — which need a complete click — did not.
|
|
func testAPressAndItsReleaseCanShareANumber() throws {
|
|
let shared = WindowTargetedEvent.nextEventNumber()
|
|
var seen: [Int64] = []
|
|
for nsType in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
|
|
let event = try XCTUnwrap(
|
|
WindowTargetedEvent.makeMouseEvent(
|
|
type: nsType == .leftMouseDown ? .leftMouseDown : .leftMouseUp,
|
|
nsType: nsType,
|
|
point: CGPoint(x: 5, y: 5),
|
|
button: .left,
|
|
clickCount: 1,
|
|
windowID: 77,
|
|
windowBounds: CGRect(x: 0, y: 0, width: 50, height: 50),
|
|
eventNumber: shared
|
|
)
|
|
)
|
|
seen.append(event.getIntegerValueField(.mouseEventNumber))
|
|
}
|
|
XCTAssertEqual(seen.first, seen.last, "press and release must carry one number")
|
|
XCTAssertEqual(seen.first, Int64(shared))
|
|
}
|
|
|
|
/// Omitting the number keeps the old behaviour for lone events, so a caller
|
|
/// that has no gesture to tie together still gets a unique one.
|
|
func testALoneEventStillTakesItsOwnNumber() throws {
|
|
var seen: Set<Int64> = []
|
|
for _ in 0..<3 {
|
|
let event = try XCTUnwrap(
|
|
WindowTargetedEvent.makeMouseEvent(
|
|
type: .leftMouseDown,
|
|
nsType: .leftMouseDown,
|
|
point: CGPoint(x: 5, y: 5),
|
|
button: .left,
|
|
clickCount: 1,
|
|
windowID: 77,
|
|
windowBounds: CGRect(x: 0, y: 0, width: 50, height: 50)
|
|
)
|
|
)
|
|
seen.insert(event.getIntegerValueField(.mouseEventNumber))
|
|
}
|
|
XCTAssertEqual(seen.count, 3, "distinct events must not collide")
|
|
}
|
|
|
|
/// Source guard: both halves of a click must read the same variable. A
|
|
/// per-event `nextEventNumber()` in the burst builder would compile, pass
|
|
/// every field assertion above, and quietly restore the split pairing.
|
|
func testClickBurstBindsPressAndReleaseToOneNumber() throws {
|
|
let source = try String(
|
|
contentsOfFile: sourcePath("AXAction.swift"),
|
|
encoding: .utf8
|
|
)
|
|
let pairs = source.components(separatedBy: "eventNumber: clickNumber").count - 1
|
|
XCTAssertEqual(
|
|
pairs, 2,
|
|
"the press and the release of a coordinate click must share one number"
|
|
)
|
|
let gesture = source.components(separatedBy: "eventNumber: gestureNumber").count - 1
|
|
XCTAssertEqual(gesture, 2, "a drag's press and release must share one number")
|
|
}
|
|
|
|
/// Source guard: the floor is easy to reintroduce as "defensive" code, and
|
|
/// it would silently undo every assertion above — the builders would keep
|
|
/// asking for 0 and the event would keep saying 1.
|
|
func testClickCountIsNotFlooredAtOne() throws {
|
|
let source = try String(
|
|
contentsOfFile: sourcePath("AXAction.swift"),
|
|
encoding: .utf8
|
|
)
|
|
XCTAssertFalse(
|
|
source.contains("max(1, spec.clickState)"),
|
|
"clickCount must be passed through; flooring it at 1 re-raises movement to click 1"
|
|
)
|
|
}
|
|
|
|
private func sourcePath(_ name: String) -> String {
|
|
URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent() // CuHelperTests
|
|
.deletingLastPathComponent() // Tests
|
|
.deletingLastPathComponent() // package root
|
|
.appendingPathComponent("Sources/cu-helper/\(name)")
|
|
.path
|
|
}
|
|
}
|