diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fde11..c6df3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- iOS directional gesture presets (`gesture scroll-up/down/left/right`, `swipe-from-*-edge`) are now orientation-aware (#66). They previously emitted device-native portrait axes, so on a rotated device `scroll-up` pointed 90° (landscape) or 180° (upside-down) away from its name — no scroll, or an unintended row navigation. Preset math now runs in the current visual space (auto-calibrated per command, the same #34 mapping `tap` selectors use) and the endpoints are transformed into HID coordinates; this covers both the standalone command and `ios batch` gesture steps (which share the batch-wide calibration). When the orientation cannot be determined the gesture falls back to the legacy portrait dispatch and says so via the `advisory` envelope key. Explicit `swipe`/`touch` coordinates remain device-native portrait by contract; pinch/rotate presets are likewise unchanged. + +### Changed + +- `gesture --screen-width/--screen-height` on iOS: for **single-finger presets** they now describe a visual-space canvas and default to the auto-detected screen size of the current orientation (previously always 390×844, iPhone 15 portrait — wrong for iPads and every landscape state); 390×844 survives only when the screen size cannot be probed. **Pinch/rotate presets** keep the raw device-native portrait canvas with the fixed 390×844 default. + ### Added - The agent-eval suite can now pin exactly which sim-use binary a run evaluates: `make eval ARGS="-b "` / `scripts/eval.sh --sim-use ` / `run.py --sim-use ` (default remains whatever `sim-use` resolves to on PATH). The wrapper and runner print `sim-use under test: ()` up front and the report header records it, so a run can never silently exercise the wrong binary — and development builds under `.build/` can be evaluated directly. New repo skill `.claude/skills/run-evals/` orchestrates the whole flow for agents and contributors: environment prep (Device Hub closed, fixtures installed), binary selection, cost confirmation, and verdict triage; `e2e/agent-evals/README.md` documents the new prereqs and flags. diff --git a/README.md b/README.md index 4f57aeb..6a125ab 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,8 @@ sim-use gesture scroll-down --pre-delay 0.5 --post-delay 1.0 --device $UDID `--pre-delay` / `--post-delay` / `--duration` work on `tap`, `swipe`, and `gesture` alike for coarse timing control. +Single-finger presets (`scroll-*`, `swipe-from-*-edge`) name **visual** directions and are orientation-aware on iOS: their canvas size and rotation are auto-detected per command, so `scroll-up` scrolls the on-screen content up whether the device is portrait, landscape, or upside-down. Explicit `swipe`/`touch` coordinates remain device-native portrait space by contract; pinch/rotate presets are likewise untransformed. + ### Text input ```bash diff --git a/Sources/SimUse/Commands/Gesture.swift b/Sources/SimUse/Commands/Gesture.swift index 5c52e4e..b19d7fb 100644 --- a/Sources/SimUse/Commands/Gesture.swift +++ b/Sources/SimUse/Commands/Gesture.swift @@ -37,9 +37,12 @@ struct Gesture: SimUseExecutableCommand { sim-use gesture rotate-cw --angle 45 --udid SIMULATOR_UDID Platforms: - * iOS — coordinates default to iPhone 15 (390×844); pass --screen-width - and --screen-height for other devices. HID granularity is controlled - by --delta (single-finger) or --steps / --step-ms (multi-touch). + * iOS — single-finger presets are orientation-aware: their math runs + in the current visual space (auto-detected size and rotation), so + scroll-up scrolls content up on a rotated device too. Pinch/rotate + presets remain device-native portrait space (390×844 default). + HID granularity is controlled by --delta (single-finger) or + --steps / --step-ms (multi-touch). * Android — coordinates default to the device's real display in pixels (auto-detected via the bridge). --delta, --steps, --step-ms are iOS-HID-specific and silently ignored on Android, since @@ -50,10 +53,10 @@ struct Gesture: SimUseExecutableCommand { @Argument(help: "The gesture preset to perform.") var preset: GesturePreset - @Option(name: .customLong("screen-width"), help: "Screen width in points (default: 390 for iPhone 15).") + @Option(name: .customLong("screen-width"), help: "Canvas width for the preset math. iOS single-finger presets: visual space, auto-detected by default (390 fallback); iOS pinch/rotate presets: device-native portrait, fixed 390 default. Android: real display pixels, auto-detected for all presets.") var screenWidth: Double? - @Option(name: .customLong("screen-height"), help: "Screen height in points (default: 844 for iPhone 15).") + @Option(name: .customLong("screen-height"), help: "Canvas height for the preset math. iOS single-finger presets: visual space, auto-detected by default (844 fallback); iOS pinch/rotate presets: device-native portrait, fixed 844 default. Android: real display pixels, auto-detected for all presets.") var screenHeight: Double? @Option(name: .customLong("duration"), help: "Duration of the gesture in seconds. Defaults to the preset baseline (0.3s edge / 0.5s scroll+pinch+rotate), except rotate presets auto-extend to |angle|/180s for sweeps > 90° so angular velocity stays near 180°/sec (recogniser sweet spot). Pass explicitly to override.") diff --git a/Sources/iOSSimBackend/A11y/AccessibilityFetcher.swift b/Sources/iOSSimBackend/A11y/AccessibilityFetcher.swift index 094d08d..39769c0 100644 --- a/Sources/iOSSimBackend/A11y/AccessibilityFetcher.swift +++ b/Sources/iOSSimBackend/A11y/AccessibilityFetcher.swift @@ -248,6 +248,33 @@ public struct AccessibilityFetcher { return FetchResult(data: data, calibration: finalCalibration) } + /// Orientation calibration WITHOUT the collapsed-children recovery + /// walk — for verbs that need the current orientation but not the + /// tree itself (directional gesture presets, issue #66). Costs one + /// tree-fetch XPC plus at most `OrientationCalibrator.defaultMaxProbes` + /// hit-test probes; recovery's quadtree probing (up to hundreds of + /// XPC round-trips on WebView-heavy screens) is skipped entirely. + public static func fetchOrientationCalibration( + for simulatorUDID: String, + logger: SimUseLogger + ) async throws -> OrientationCalibration { + let simulatorSet = try await getSimulatorSet( + deviceSetPath: nil, + logger: logger, + reporter: EmptyEventReporter.shared + ) + guard let target = simulatorSet.allSimulators.first(where: { $0.udid == simulatorUDID }) else { + throw CLIError(errorDescription: "Simulator with UDID \(simulatorUDID) not found in set.") + } + let native = NativePortraitSize(screenInfo: target.screenInfo) + let probe: CollapsedChildrenRecovery.PointProbe = { point in + let raw: AnyObject = try await target.legacyAccessibilityElement(at: point, nestedFormat: false) + return raw as? [String: Any] + } + let info: AnyObject = try await target.legacyAccessibilityElements(nestedFormat: true) + return await calibrate(info: info, native: native, probe: probe, logger: logger) + } + // MARK: - Calibration over the raw tree payload private static func calibrate( diff --git a/Sources/iOSSimBackend/A11y/GestureOrientationMapping.swift b/Sources/iOSSimBackend/A11y/GestureOrientationMapping.swift new file mode 100644 index 0000000..7691e67 --- /dev/null +++ b/Sources/iOSSimBackend/A11y/GestureOrientationMapping.swift @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import SimUseCore + +/// Pure decision layer for orientation-aware directional gestures +/// (issue #66). Named presets (`scroll-*`, `swipe-from-*-edge`) +/// describe VISUAL directions, so their stroke math must run in the +/// UI space of the current interface orientation and the resulting +/// endpoints must cross into the native-portrait framebuffer space +/// the HID layer consumes — the same `uiToFramebuffer` mapping tap +/// selectors ride (issue #34). Explicit user coordinates never pass +/// through here. +public enum GestureOrientationMapping { + + /// The legacy preset canvas, kept as the last-resort fallback when + /// no native screen size is known (identity calibration). + public static let legacyWidth = 390.0 + public static let legacyHeight = 844.0 + + /// The visual-space canvas the preset math runs in. Explicit + /// `--screen-width` / `--screen-height` flags win per axis (they + /// describe a visual-space viewport); missing axes come from the + /// calibrated UI size, or the legacy 390x844 when the native size + /// is unknown. + public static func visualSize( + explicitWidth: Double?, + explicitHeight: Double?, + calibration: OrientationCalibration + ) -> (width: Double, height: Double) { + let ui = calibration.native.map { calibration.orientation.uiSize(native: $0) } + return ( + explicitWidth ?? ui?.width ?? legacyWidth, + explicitHeight ?? ui?.height ?? legacyHeight + ) + } + + /// Maps one UI-space stroke's endpoints into HID (native-portrait + /// framebuffer) coordinates. Identity (portrait or no native size) + /// passes the stroke through bit-for-bit. + public static func hidStroke( + _ stroke: GesturePreset.Stroke, + calibration: OrientationCalibration + ) -> (startX: Double, startY: Double, endX: Double, endY: Double) { + let start = calibration.hidCGPoint(CGPoint(x: stroke.startX, y: stroke.startY)) + let end = calibration.hidCGPoint(CGPoint(x: stroke.endX, y: stroke.endY)) + return (Double(start.x), Double(start.y), Double(end.x), Double(end.y)) + } +} diff --git a/Sources/iOSSimBackend/Batch/Command+BatchConvertible.swift b/Sources/iOSSimBackend/Batch/Command+BatchConvertible.swift index 08919d9..3472e10 100644 --- a/Sources/iOSSimBackend/Batch/Command+BatchConvertible.swift +++ b/Sources/iOSSimBackend/Batch/Command+BatchConvertible.swift @@ -123,12 +123,38 @@ extension IOSSimSwipeCommand: BatchConvertible { extension IOSSimGestureCommand: BatchConvertible { public func toBatchPrimitives(context: BatchContext, logger: SimUseLogger) async throws -> [BatchPrimitive] { - let width = screenWidth ?? 390.0 - let height = screenHeight ?? 844.0 - let coords = preset.coordinates(screenWidth: width, screenHeight: height) let gestureDuration = duration ?? preset.defaultDuration let gestureDelta = delta ?? preset.defaultDelta + let coords: (startX: Double, startY: Double, endX: Double, endY: Double) + if preset.isMultiTouch { + // Multi-touch presets keep the legacy raw dispatch (see the + // standalone path for the scope rationale). + let width = screenWidth ?? GestureOrientationMapping.legacyWidth + let height = screenHeight ?? GestureOrientationMapping.legacyHeight + coords = preset.coordinates(screenWidth: width, screenHeight: height) + } else { + // Directional presets ride the batch-wide calibration + // (issue #66) — computed lazily once per run and shared + // with tap selector steps. An unreachable AX tree degrades + // to an identity dispatch with a recorded advisory instead + // of failing the whole batch. + let calibration: OrientationCalibration + if let roots = try? await context.accessibilityRoots(logger: logger) { + calibration = await context.orientationCalibration(roots: roots, logger: logger) + } else { + calibration = .identity() + context.recordAdvisory(CommandAdvisory( + kind: .orientationCalibrationFallback, + message: "Screen orientation could not be determined; '\(preset.rawValue)' was dispatched in device-native portrait axes and may point the wrong way if the device is rotated." + )) + } + let visual = GestureOrientationMapping.visualSize( + explicitWidth: screenWidth, explicitHeight: screenHeight, calibration: calibration) + let stroke = preset.strokes(screenWidth: visual.width, screenHeight: visual.height)[0] + coords = GestureOrientationMapping.hidStroke(stroke, calibration: calibration) + } + let gestureEvent = FBSimulatorHIDEvent.swipe( coords.startX, yStart: coords.startY, diff --git a/Sources/iOSSimBackend/Verbs/IOSSimGestureCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimGestureCommand.swift index 002f63d..b30979c 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimGestureCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimGestureCommand.swift @@ -10,8 +10,18 @@ import SimUseCore /// `sim-use ios gesture`. The top-level command resolves the target /// platform via `PlatformRouter` and forwards iOS UDIDs through here. public struct IOSSimGestureCommand: SimUseExecutableCommand { - public struct ExecutionResult: Codable { - public init() {} + public struct ExecutionResult: Codable, CommandAdvisoryProviding { + /// Excluded from the encoded `data` payload via `CodingKeys` + /// (the default value keeps decode synthesis working) — the + /// envelope hoists it to the top-level `advisory` key. See + /// `CommandAdvisoryProviding` for the contract. + public var commandAdvisory: CommandAdvisory? = nil + + public init(commandAdvisory: CommandAdvisory? = nil) { + self.commandAdvisory = commandAdvisory + } + + private enum CodingKeys: CodingKey {} } public static let configuration = CommandConfiguration( @@ -20,7 +30,9 @@ public struct IOSSimGestureCommand: SimUseExecutableCommand { discussion: """ Execute common gesture patterns without specifying coordinates. - Single-finger presets: + Single-finger presets (visual directions — orientation-aware, + so scroll-up scrolls the on-screen content up on a rotated + device too): scroll-up, scroll-down, scroll-left, scroll-right swipe-from-left-edge, swipe-from-right-edge swipe-from-top-edge, swipe-from-bottom-edge @@ -34,10 +46,10 @@ public struct IOSSimGestureCommand: SimUseExecutableCommand { @Argument(help: "The gesture preset to perform.") public var preset: GesturePreset - @Option(name: .customLong("screen-width"), help: "Screen width in points (default: 390 for iPhone 15).") + @Option(name: .customLong("screen-width"), help: "Canvas width in points for the preset math. Single-finger presets: visual space, auto-detected from the current orientation by default (390 when the screen size is unknown). Pinch/rotate presets: device-native portrait space, fixed 390 default.") public var screenWidth: Double? - @Option(name: .customLong("screen-height"), help: "Screen height in points (default: 844 for iPhone 15).") + @Option(name: .customLong("screen-height"), help: "Canvas height in points for the preset math. Single-finger presets: visual space, auto-detected from the current orientation by default (844 when the screen size is unknown). Pinch/rotate presets: device-native portrait space, fixed 844 default.") public var screenHeight: Double? @Option(name: .customLong("duration"), help: "Duration of the gesture in seconds (uses preset default if not specified).") @@ -144,8 +156,6 @@ public struct IOSSimGestureCommand: SimUseExecutableCommand { try await setup(logger: logger) try await performGlobalSetup(logger: logger) - let width = screenWidth ?? 390.0 - let height = screenHeight ?? 844.0 // `recommendedDuration` auto-extends rotate sweeps beyond 90° // to keep angular velocity near 180°/sec (recogniser sweet // spot). Pinch / scroll / edge presets fall through to the @@ -153,33 +163,74 @@ public struct IOSSimGestureCommand: SimUseExecutableCommand { let gestureDuration = duration ?? preset.recommendedDuration(angle: angle) logger.info().log("Performing \(preset.description)") - logger.info().log("Screen size: \(width)x\(height)") logger.info().log("Duration: \(gestureDuration)s") if preset.isMultiTouch { + // Multi-touch presets stay in device-native portrait axes: + // orientation-aware pinch/rotate would need every + // interpolated sample mapped, not just the endpoints, and + // is out of scope for the issue #66 fix. + let width = screenWidth ?? GestureOrientationMapping.legacyWidth + let height = screenHeight ?? GestureOrientationMapping.legacyHeight + logger.info().log("Screen size: \(width)x\(height)") try await runMultiTouch( width: width, height: height, duration: gestureDuration, logger: logger ) - } else { - try await runSingleTouch( - width: width, - height: height, - duration: gestureDuration, - logger: logger - ) + logger.info().log("Gesture completed successfully") + return ExecutionResult() } + // Directional presets name a visual direction, so their math + // runs in the calibrated UI space and the endpoints cross into + // HID space (issue #66) — the same mapping tap selectors use. + let calibration = await Self.orientationCalibration( + udid: device.resolved, preset: preset, logger: logger) + let visual = GestureOrientationMapping.visualSize( + explicitWidth: screenWidth, explicitHeight: screenHeight, calibration: calibration) + logger.info().log("Visual canvas: \(visual.width)x\(visual.height) (orientation: \(calibration.orientation.rawValue))") + try await runSingleTouch( + visual: visual, + calibration: calibration, + duration: gestureDuration, + logger: logger + ) + logger.info().log("Gesture completed successfully") - return ExecutionResult() + return ExecutionResult(commandAdvisory: calibration.advisory) + } + + /// The current orientation for a directional preset, degrading to + /// an identity (portrait) dispatch with an explicit advisory when + /// the simulator cannot be calibrated at all — never silently. + static func orientationCalibration( + udid: String, + preset: GesturePreset, + logger: SimUseLogger + ) async -> OrientationCalibration { + do { + return try await AccessibilityFetcher.fetchOrientationCalibration(for: udid, logger: logger) + } catch { + logger.info().log("Orientation calibration unavailable (\(error.localizedDescription)); dispatching \(preset.rawValue) in native portrait axes") + return .identity(advisory: CommandAdvisory( + kind: .orientationCalibrationFallback, + message: "Screen orientation could not be determined; '\(preset.rawValue)' was dispatched in device-native portrait axes and may point the wrong way if the device is rotated." + )) + } } - private func runSingleTouch(width: Double, height: Double, duration: Double, logger: SimUseLogger) async throws { - let coords = preset.coordinates(screenWidth: width, screenHeight: height) + private func runSingleTouch( + visual: (width: Double, height: Double), + calibration: OrientationCalibration, + duration: Double, + logger: SimUseLogger + ) async throws { + let stroke = preset.strokes(screenWidth: visual.width, screenHeight: visual.height)[0] + let coords = GestureOrientationMapping.hidStroke(stroke, calibration: calibration) let gestureDelta = delta ?? preset.defaultDelta - logger.info().log("Coordinates: (\(coords.startX), \(coords.startY)) to (\(coords.endX), \(coords.endY))") + logger.info().log("Coordinates (HID space): (\(coords.startX), \(coords.startY)) to (\(coords.endX), \(coords.endY))") logger.info().log("Delta: \(gestureDelta)px") var events: [FBSimulatorHIDEvent] = [] diff --git a/Tests/BatchGestureOrientationTests.swift b/Tests/BatchGestureOrientationTests.swift new file mode 100644 index 0000000..9147bb2 --- /dev/null +++ b/Tests/BatchGestureOrientationTests.swift @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +@testable import iOSSimBackend +import Foundation +import SimUseCore +import Testing + +// Batch gesture steps have their own primitive-construction path +// (issue #66): directional presets must ride the batch-wide +// calibration exactly like tap selector steps — computed once per +// run, shared across steps, degrading to an identity dispatch with a +// recorded advisory instead of failing the batch. Multi-touch presets +// stay on the raw legacy path and must not pay for a tree fetch. +// Endpoint math itself is pinned by GestureOrientationMappingTests. + +private func makeSmallTree() throws -> [AccessibilityElement] { + let json = """ + [{"type": "Application", "AXLabel": "App", "frame": {"x": 0, "y": 0, "width": 1376, "height": 1032}, "children": [ + {"type": "Button", "AXLabel": "Corner", "frame": {"x": 10, "y": 900, "width": 100, "height": 40}, "enabled": true} + ]}] + """ + return try JSONDecoder().decode([AccessibilityElement].self, from: Data(json.utf8)) +} + +private let quietLogger = SimUseLogger(writeToStdErr: false) + +@MainActor +private final class CalibratorSpy { + private(set) var calls = 0 + let result: OrientationCalibration + + init(result: OrientationCalibration) { + self.result = result + } + + func record() -> OrientationCalibration { + calls += 1 + return result + } +} + +@Suite("Batch — gesture orientation") +@MainActor +struct BatchGestureOrientationTests { + private func parseStep(_ tokens: [String], context: BatchContext) async throws -> [BatchPrimitive] { + context.beginStep() + return try await BatchStepParser.parseStepTokens( + tokens, + globalUDID: "FAKE-UDID", + context: context, + logger: quietLogger + ) + } + + @Test("Directional gesture steps share one batch-wide calibration") + func directionalStepsShareCalibration() async throws { + let spy = CalibratorSpy(result: OrientationCalibration( + orientation: .landscapeRight, + native: NativePortraitSize(width: 1032, height: 1376), + probesUsed: 1, + advisory: nil + )) + let tree = try makeSmallTree() + let context = BatchContext( + simulatorUDID: "FAKE-UDID", + axCachePolicy: .perBatch, + typeSubmissionMode: .chunked, + typeChunkSize: 200, + fetchElements: { _, _ in tree }, + calibrator: { _, _, _ in spy.record() } + ) + + let first = try await parseStep(["gesture", "scroll-up"], context: context) + let second = try await parseStep(["gesture", "scroll-left"], context: context) + + #expect(first.count == 1) + #expect(second.count == 1) + #expect(spy.calls == 1) + #expect(context.commandAdvisories.isEmpty) + } + + @Test("Multi-touch presets never pay for a tree fetch or calibration") + func multiTouchStaysRaw() async throws { + let spy = CalibratorSpy(result: .identity()) + let context = BatchContext( + simulatorUDID: "FAKE-UDID", + axCachePolicy: .perBatch, + typeSubmissionMode: .chunked, + typeChunkSize: 200, + fetchElements: { _, _ in + Issue.record("multi-touch preset should not fetch the AX tree") + return [] + }, + calibrator: { _, _, _ in spy.record() } + ) + + let primitives = try await parseStep(["gesture", "pinch-in"], context: context) + + #expect(primitives.count == 1) + #expect(spy.calls == 0) + } + + @Test("An unreachable AX tree degrades to identity with a recorded advisory") + func fetchFailureDegradesWithAdvisory() async throws { + let context = BatchContext( + simulatorUDID: "FAKE-UDID", + axCachePolicy: .perBatch, + typeSubmissionMode: .chunked, + typeChunkSize: 200, + fetchElements: { _, _ in throw CLIError(errorDescription: "no simulator") }, + calibrator: { _, _, _ in + Issue.record("calibrator should not run when the tree fetch fails") + return .identity() + } + ) + + let primitives = try await parseStep(["gesture", "scroll-up"], context: context) + + #expect(primitives.count == 1) + #expect(context.commandAdvisories.count == 1) + let advisory = try #require(context.commandAdvisories.first) + #expect(advisory.kind == .orientationCalibrationFallback) + #expect(advisory.message.hasPrefix("Step 1: ")) + #expect(advisory.message.contains("scroll-up")) + } +} diff --git a/Tests/CommandAdvisoryContractTests.swift b/Tests/CommandAdvisoryContractTests.swift index 1920904..4be5b7c 100644 --- a/Tests/CommandAdvisoryContractTests.swift +++ b/Tests/CommandAdvisoryContractTests.swift @@ -35,6 +35,18 @@ struct CommandAdvisoryContractTests { #expect(decoded.y == 20) } + @Test("gesture result encodes without the advisory and decodes it as nil") + func gestureResult() throws { + let result = IOSSimGestureCommand.ExecutionResult(commandAdvisory: contractAdvisory) + #expect(result.commandAdvisory == contractAdvisory) + + let json = try encodedJSON(result) + #expect(json == #"{}"#) + + let decoded = try JSONDecoder().decode(IOSSimGestureCommand.ExecutionResult.self, from: Data(json.utf8)) + #expect(decoded.commandAdvisory == nil) + } + @Test("batch result encodes without the advisory and decodes it as nil") func batchResult() throws { let result = IOSSimBatchCommand.ExecutionResult(stepsExecuted: 3, commandAdvisory: contractAdvisory) diff --git a/Tests/GestureOrientationMappingTests.swift b/Tests/GestureOrientationMappingTests.swift new file mode 100644 index 0000000..ca23d76 --- /dev/null +++ b/Tests/GestureOrientationMappingTests.swift @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +@testable import iOSSimBackend +import Foundation +import SimUseCore +import Testing + +// Directional gesture presets (`scroll-*`, `swipe-from-*-edge`) name a +// VISUAL direction, but the HID layer consumes device-native portrait +// coordinates — so on a rotated device an untransformed preset points +// 90°/180° away from what its name promises (issue #66; scroll-up on a +// landscape iPad scrolled nothing while a native-horizontal swipe +// scrolled the list vertically). +// +// `GestureOrientationMapping` is the pure decision layer for the fix: +// resolve the visual screen size the preset math runs in (explicit +// flags win, else the calibrated UI size, else the legacy 390x844), +// then carry each UI-space stroke endpoint across `uiToFramebuffer`. +// The underlying per-orientation formulas are pinned by +// DisplayOrientationTests; these tests pin the preset→HID composition. + +private let iPadNative = NativePortraitSize(width: 1032, height: 1376) + +private func calibration(_ orientation: DisplayOrientation, native: NativePortraitSize? = iPadNative) -> OrientationCalibration { + OrientationCalibration(orientation: orientation, native: native, probesUsed: 0, advisory: nil) +} + +@Suite("GestureOrientationMapping.visualSize") +struct GestureVisualSizeTests { + + @Test("Explicit flags win over the calibrated size") + func explicitFlagsWin() { + let size = GestureOrientationMapping.visualSize( + explicitWidth: 800, explicitHeight: 600, + calibration: calibration(.landscapeRight)) + #expect(size.width == 800 && size.height == 600) + } + + @Test("A partially explicit size fills the other axis from the calibrated UI size") + func partialExplicitFillsFromUISize() { + let size = GestureOrientationMapping.visualSize( + explicitWidth: 800, explicitHeight: nil, + calibration: calibration(.landscapeRight)) + #expect(size.width == 800 && size.height == 1032) + } + + @Test("Portrait default is the native size") + func portraitDefaultIsNative() { + let size = GestureOrientationMapping.visualSize( + explicitWidth: nil, explicitHeight: nil, + calibration: calibration(.portrait)) + #expect(size.width == 1032 && size.height == 1376) + } + + @Test("Landscape default swaps the native dimensions") + func landscapeDefaultSwaps() { + let size = GestureOrientationMapping.visualSize( + explicitWidth: nil, explicitHeight: nil, + calibration: calibration(.landscapeRight)) + #expect(size.width == 1376 && size.height == 1032) + } + + @Test("Without a native size the legacy 390x844 default survives") + func noNativeFallsBackToLegacy() { + let size = GestureOrientationMapping.visualSize( + explicitWidth: nil, explicitHeight: nil, + calibration: calibration(.portrait, native: nil)) + #expect(size.width == 390 && size.height == 844) + } +} + +@Suite("GestureOrientationMapping.hidStroke") +struct GestureHIDStrokeTests { + + /// Runs the full preset pipeline the way the command does: preset + /// math in the calibrated visual size, then endpoint mapping. + private func hidEndpoints( + _ preset: GesturePreset, + _ orientation: DisplayOrientation + ) -> (startX: Double, startY: Double, endX: Double, endY: Double) { + let cal = calibration(orientation) + let visual = GestureOrientationMapping.visualSize( + explicitWidth: nil, explicitHeight: nil, calibration: cal) + let stroke = preset.strokes(screenWidth: visual.width, screenHeight: visual.height)[0] + return GestureOrientationMapping.hidStroke(stroke, calibration: cal) + } + + private func expect( + _ got: (startX: Double, startY: Double, endX: Double, endY: Double), + _ want: (Double, Double, Double, Double), + _ label: String + ) { + #expect(got.startX == want.0 && got.startY == want.1 + && got.endX == want.2 && got.endY == want.3, + "\(label): got (\(got.startX),\(got.startY))→(\(got.endX),\(got.endY)), want (\(want.0),\(want.1))→(\(want.2),\(want.3))") + } + + @Test("Portrait strokes pass through unchanged") + func portraitIsIdentity() { + expect(hidEndpoints(.scrollUp, .portrait), (516, 860, 516, 516), "scroll-up") + expect(hidEndpoints(.scrollDown, .portrait), (516, 516, 516, 860), "scroll-down") + expect(hidEndpoints(.scrollLeft, .portrait), (645, 688, 387, 688), "scroll-left") + expect(hidEndpoints(.scrollRight, .portrait), (387, 688, 645, 688), "scroll-right") + } + + @Test("Landscape-right scrolls emit along the native-horizontal axis") + func landscapeRightScrolls() { + // The issue #66 field case: visual-vertical scrolling must come + // out as native-x motion (f = (W−uy, ux)). + expect(hidEndpoints(.scrollUp, .landscapeRight), (387, 688, 645, 688), "scroll-up") + expect(hidEndpoints(.scrollDown, .landscapeRight), (645, 688, 387, 688), "scroll-down") + expect(hidEndpoints(.scrollLeft, .landscapeRight), (516, 860, 516, 516), "scroll-left") + expect(hidEndpoints(.scrollRight, .landscapeRight), (516, 516, 516, 860), "scroll-right") + } + + @Test("Landscape-left scrolls mirror landscape-right") + func landscapeLeftScrolls() { + // f = (uy, H−ux) + expect(hidEndpoints(.scrollUp, .landscapeLeft), (645, 688, 387, 688), "scroll-up") + expect(hidEndpoints(.scrollDown, .landscapeLeft), (387, 688, 645, 688), "scroll-down") + expect(hidEndpoints(.scrollLeft, .landscapeLeft), (516, 516, 516, 860), "scroll-left") + expect(hidEndpoints(.scrollRight, .landscapeLeft), (516, 860, 516, 516), "scroll-right") + } + + @Test("Upside-down scrolls are the portrait strokes mirrored on both axes") + func upsideDownScrolls() { + // f = (W−ux, H−uy) + expect(hidEndpoints(.scrollUp, .portraitUpsideDown), (516, 516, 516, 860), "scroll-up") + expect(hidEndpoints(.scrollDown, .portraitUpsideDown), (516, 860, 516, 516), "scroll-down") + expect(hidEndpoints(.scrollLeft, .portraitUpsideDown), (387, 688, 645, 688), "scroll-left") + expect(hidEndpoints(.scrollRight, .portraitUpsideDown), (645, 688, 387, 688), "scroll-right") + } + + @Test("Edge presets track the visual edges under landscape-right") + func landscapeRightEdges() { + // Visual left edge = native top edge under landscape-right, etc. + expect(hidEndpoints(.swipeFromLeftEdge, .landscapeRight), (516, 20, 516, 1356), "left-edge") + expect(hidEndpoints(.swipeFromRightEdge, .landscapeRight), (516, 1356, 516, 20), "right-edge") + expect(hidEndpoints(.swipeFromTopEdge, .landscapeRight), (1012, 688, 20, 688), "top-edge") + expect(hidEndpoints(.swipeFromBottomEdge, .landscapeRight), (20, 688, 1012, 688), "bottom-edge") + } + + @Test("A calibration without a native size leaves strokes untouched") + func noNativeIsIdentity() { + let cal = calibration(.landscapeRight, native: nil) + let stroke = GesturePreset.scrollUp.strokes(screenWidth: 390, screenHeight: 844)[0] + let got = GestureOrientationMapping.hidStroke(stroke, calibration: cal) + #expect(got.startX == stroke.startX && got.startY == stroke.startY + && got.endX == stroke.endX && got.endY == stroke.endY) + } +}