diff --git a/companion/Sources/Gesture.swift b/companion/Sources/Gesture.swift new file mode 100644 index 0000000..4357972 --- /dev/null +++ b/companion/Sources/Gesture.swift @@ -0,0 +1,89 @@ +import XCTest +import CoreGraphics + +// Synthesizes a single timestamped touch event from a list of primitive events. +// A running offset (seconds) is advanced as events are consumed so sequential +// taps collapse into one pointer path with press/lift/press/lift at exact +// offsets, honoring the inter-tap gap to the millisecond. +enum Gesture { + enum GestureError: Error { + case missingField(String) + case unknownKind(String) + case synthesisTimeout + case synthesisFailed(String) + } + + static func perform(events: [[String: Any]]) throws { + var offset = 0.0 + var path: XCPointerEventPath? + + func point(_ event: [String: Any], _ xKey: String, _ yKey: String) throws -> CGPoint { + guard let x = event[xKey] as? Double, let y = event[yKey] as? Double else { + throw GestureError.missingField("\(xKey)/\(yKey)") + } + return CGPoint(x: x, y: y) + } + + func ensurePath(at location: CGPoint) -> XCPointerEventPath { + if let existing = path { + existing.move(to: location, atOffset: offset) + return existing + } + let created = XCPointerEventPath(forTouchAt: location, offset: offset) + path = created + return created + } + + for event in events { + guard let kind = event["kind"] as? String else { + throw GestureError.missingField("kind") + } + switch kind { + case "touchDown": + let location = try point(event, "x", "y") + ensurePath(at: location).pressDown(atOffset: offset) + case "touchUp": + let location = try point(event, "x", "y") + ensurePath(at: location).liftUp(atOffset: offset) + case "delay": + guard let milliseconds = event["milliseconds"] as? Double else { + throw GestureError.missingField("milliseconds") + } + offset += milliseconds / 1000.0 + case "swipe": + let from = try point(event, "fromX", "fromY") + let to = try point(event, "toX", "toY") + guard let seconds = event["seconds"] as? Double else { + throw GestureError.missingField("seconds") + } + let activePath = ensurePath(at: from) + activePath.pressDown(atOffset: offset) + activePath.move(to: to, atOffset: offset + seconds) + activePath.liftUp(atOffset: offset + seconds) + offset += seconds + default: + throw GestureError.unknownKind(kind) + } + } + + guard let path = path else { + throw GestureError.missingField("events") + } + + let record = XCSynthesizedEventRecord(name: "companion", interfaceOrientation: 0) + record.add(path) + + let semaphore = DispatchSemaphore(value: 0) + var synthesisError: Error? + XCTRunnerDaemonSession.shared().synthesize(event: record) { error in + synthesisError = error + semaphore.signal() + } + if semaphore.wait(timeout: .now() + 30) == .timedOut { + throw GestureError.synthesisTimeout + } + if let synthesisError = synthesisError { + throw GestureError.synthesisFailed("\(synthesisError)") + } + } +}