From 8de6ff8771ad25d05db540bbcec05c5db804454c Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 22:38:34 +0800 Subject: [PATCH] Show current hit target size suggestions in Gesture Debug --- LookInside.xcodeproj/project.pbxproj | 2 + .../Base/LookinClient-Bridging-Header.h | 1 + .../GestureDebug/LKGestureCaptureModels.swift | 3 + .../GestureDebug/LKGestureDebugSession.swift | 23 +++- .../GestureDebug/LKGestureDebugView.swift | 16 ++- .../LKNativeInteractionDetailView.swift | 2 +- LookInside/GestureDebug/LKSuggestions.swift | 123 ++++++++++++++++++ .../GestureDebug/LKSuggestionsView.swift | 92 +++++++++++++ Scripts/test-gesture-models.sh | 4 +- Sources/LookinCore/LookinHitTargetSize.h | 23 ++++ Sources/LookinCore/include/LookinCore.h | 1 + .../LKGestureCaptureModelsTests.swift | 64 +++++++++ 12 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 LookInside/GestureDebug/LKSuggestions.swift create mode 100644 LookInside/GestureDebug/LKSuggestionsView.swift create mode 100644 Sources/LookinCore/LookinHitTargetSize.h diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index c11ba71f..c661ba2e 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -221,6 +221,8 @@ GestureDebug/LKGestureDebugWindowController.swift, GestureDebug/LKGestureNodeDetailView.swift, GestureDebug/LKNativeInteractionDetailView.swift, + GestureDebug/LKSuggestions.swift, + GestureDebug/LKSuggestionsView.swift, MCPBridge/LKMCPBridgeAttributeValueDecoder.swift, MCPBridge/LKMCPBridgeConnection.swift, MCPBridge/LKMCPBridgeDetailsService.swift, diff --git a/LookInside/Base/LookinClient-Bridging-Header.h b/LookInside/Base/LookinClient-Bridging-Header.h index b0145918..aaba961b 100644 --- a/LookInside/Base/LookinClient-Bridging-Header.h +++ b/LookInside/Base/LookinClient-Bridging-Header.h @@ -27,6 +27,7 @@ #import "LookinDisplayItem+LookinClient.h" #import "LookinObject.h" #import "LookinAppInfo.h" +#import "LookinHitTargetSize.h" #import "LookinHierarchyInfo.h" #import "LookinHierarchyFile.h" #import "LookinArchiveDocument.h" diff --git a/LookInside/GestureDebug/LKGestureCaptureModels.swift b/LookInside/GestureDebug/LKGestureCaptureModels.swift index 7da98356..76434a96 100644 --- a/LookInside/GestureDebug/LKGestureCaptureModels.swift +++ b/LookInside/GestureDebug/LKGestureCaptureModels.swift @@ -15,6 +15,7 @@ struct LKGestureCaptureBatch: Codable { var pollDurationMS: Double var overlayStatus: LKGestureOverlayStatus? var nativeRegions: [LKNativeInteractionRegion]? + var interactions: LKInteractionSnapshot? } struct LKNativeInteractionRegion: Codable, Identifiable { @@ -123,6 +124,8 @@ struct LKGestureCaptureArchive: Codable { var snapshots: [LKGestureCaptureSnapshot] var records: [LKGestureCaptureRecord] var nativeRegions: [LKNativeInteractionRegion]? + var interactions: LKInteractionSnapshot? + var suggestionPlatform: LKSuggestionPlatform? } struct LKGestureTreeItem: Identifiable { diff --git a/LookInside/GestureDebug/LKGestureDebugSession.swift b/LookInside/GestureDebug/LKGestureDebugSession.swift index 4520475c..d8ad6d4f 100644 --- a/LookInside/GestureDebug/LKGestureDebugSession.swift +++ b/LookInside/GestureDebug/LKGestureDebugSession.swift @@ -22,6 +22,9 @@ final class LKGestureDebugSession: ObservableObject { @Published private(set) var overlayStatus: LKGestureOverlayStatus? @Published private(set) var nativeRegions: [LKNativeInteractionRegion] = [] @Published private var lastNativeRegions: [LKNativeInteractionRegion] = [] + @Published private(set) var suggestions = LKSuggestionReport() + @Published private(set) var suggestionPlatform = LKSuggestionPlatform.unsupported + private var lastInteractions: LKInteractionSnapshot? @Published private(set) var appName = "No target" @Published private(set) var supported = false @@ -38,7 +41,7 @@ final class LKGestureDebugSession: ObservableObject { } var canExport: Bool { - !records.isEmpty || !snapshots.isEmpty || !lastNativeRegions.isEmpty + !records.isEmpty || !snapshots.isEmpty || !lastNativeRegions.isEmpty || lastInteractions?.targets.isEmpty == false } init() { @@ -72,6 +75,13 @@ final class LKGestureDebugSession: ObservableObject { self.app = app channel = app?.channel appName = app?.appInfo?.appName ?? "No target" + if let info = app?.appInfo { + suggestionPlatform = .resolve(deviceType: info.deviceType.rawValue, model: info.deviceModelIdentifier ?? "", + deviceName: info.deviceDescription ?? "", os: info.osDescription ?? "") + } else { + suggestionPlatform = .unsupported + } + lastInteractions = nil supported = (app?.appInfo?.gestureDebugProtocolVersion ?? 0) >= 1 snapshots.removeAll() records.removeAll() @@ -97,6 +107,8 @@ final class LKGestureDebugSession: ObservableObject { nativeRegions.removeAll() lastNativeRegions.removeAll() snapshots.removeAll() + lastInteractions = nil + suggestions.replace(with: nil, platform: suggestionPlatform, isCapturing: true) records.removeAll() selectedSnapshotID = nil selectedNodeID = nil @@ -133,6 +145,7 @@ final class LKGestureDebugSession: ObservableObject { overlayStatus = nil nativeRegions.removeAll() state = "stopped" + suggestions.replace(with: nil, platform: suggestionPlatform, isCapturing: false) if token != nil { message = "Capture stopped. Recorded events remain available." } @@ -189,7 +202,8 @@ final class LKGestureDebugSession: ObservableObject { func archiveData() throws -> Data { let archive = LKGestureCaptureArchive( appName: appName, bundleIdentifier: app?.appInfo?.appBundleIdentifier ?? "", - sessionID: lastSessionID, snapshots: snapshots, records: records, nativeRegions: lastNativeRegions + sessionID: lastSessionID, snapshots: snapshots, records: records, nativeRegions: lastNativeRegions, + interactions: lastInteractions, suggestionPlatform: suggestionPlatform ) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] @@ -224,6 +238,10 @@ final class LKGestureDebugSession: ObservableObject { state = batch.state isStarting = state == "starting" isRunning = state == "waiting" || state == "capturing" || state == "redacted" + suggestions.replace(with: batch.interactions, platform: suggestionPlatform, isCapturing: isRunning || isStarting) + if isRunning || isStarting { + lastInteractions = batch.interactions + } message = gap ? "A capture batch was lost. Some event details may be incomplete." : batch.message if batch.state == "stopped" || batch.state == "error" { isRunning = false @@ -248,6 +266,7 @@ final class LKGestureDebugSession: ObservableObject { selectedNodeID = nil } } catch { + suggestions.replace(with: nil, platform: suggestionPlatform, isCapturing: isRunning || isStarting) message = "Could not decode the target's gesture capture: \(error.localizedDescription)" state = "error" } diff --git a/LookInside/GestureDebug/LKGestureDebugView.swift b/LookInside/GestureDebug/LKGestureDebugView.swift index 553ce141..48b8f21f 100644 --- a/LookInside/GestureDebug/LKGestureDebugView.swift +++ b/LookInside/GestureDebug/LKGestureDebugView.swift @@ -11,6 +11,7 @@ struct LKGestureDebugView: View { case gestures = "Gestures" case bindings = "Bindings" case native = "Native" + case suggestions = "Suggestions" case hitTest = "Hit Test" case raw = "Raw Log" } @@ -29,7 +30,9 @@ struct LKGestureDebugView: View { } .pickerStyle(.segmented) .padding(12) - if section == .native { + if section == .suggestions { + LKSuggestionsView(report: session.suggestions, platform: session.suggestionPlatform) + } else if section == .native { LKNativeInteractionDetailView(regions: session.nativeRegions, isRunning: session.isRunning, overlayEnabled: session.overlayEnabled) } else if let snapshot = session.selectedSnapshot { snapshotContent(snapshot) @@ -100,6 +103,15 @@ struct LKGestureDebugView: View { Text("\(session.nativeRegions.count) native controls / list rows · See Native for handler details.") .font(.caption).foregroundStyle(.secondary) } + if session.isRunning { + Button { section = .suggestions } label: { + Label("Suggestions · \(session.suggestions.warnings.count) warnings · \(session.suggestions.inspectedCount) regions inspected", + systemImage: "exclamationmark.triangle") + } + .buttonStyle(.link).font(.caption) + .foregroundStyle(session.suggestions.warnings.isEmpty ? Color.secondary : .orange) + .accessibilityIdentifier("gesture.suggestions.summary") + } if session.state == "redacted" { DisclosureGroup("Target Debug build configuration") { Text(""" @@ -181,7 +193,7 @@ struct LKGestureDebugView: View { .padding(.horizontal, 14).padding(.bottom, 8) } switch section { - case .native: EmptyView() + case .native, .suggestions: EmptyView() case .responders: tree(snapshot.responders, snapshot: snapshot) case .gestures: tree(snapshot.gestures, snapshot: snapshot) case .bindings: diff --git a/LookInside/GestureDebug/LKNativeInteractionDetailView.swift b/LookInside/GestureDebug/LKNativeInteractionDetailView.swift index 5f657d5a..fe2316fa 100644 --- a/LookInside/GestureDebug/LKNativeInteractionDetailView.swift +++ b/LookInside/GestureDebug/LKNativeInteractionDetailView.swift @@ -15,7 +15,7 @@ struct LKNativeInteractionDetailView: View { .font(.caption).foregroundStyle(.secondary).padding(.horizontal, 12).padding(.bottom, 12) Divider() if regions.isEmpty { - Text(isRunning && overlayEnabled ? "No supported native interactions on this page. Native collection currently supports UIKit." : "Start capture with Gesture borders enabled to inspect native regions.") + Text(isRunning ? "No supported native interactions on this page. Native collection currently supports UIKit." : "Start capture to inspect native regions.") .foregroundStyle(.secondary).padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { diff --git a/LookInside/GestureDebug/LKSuggestions.swift b/LookInside/GestureDebug/LKSuggestions.swift new file mode 100644 index 00000000..9153cb57 --- /dev/null +++ b/LookInside/GestureDebug/LKSuggestions.swift @@ -0,0 +1,123 @@ +import Foundation + +struct LKInteractionSnapshot: Codable { + var targets: [LKInteractionTarget] + var swiftUIStatus: String + var nativeStatus: String + var truncated: Bool +} + +struct LKInteractionTarget: Codable, Identifiable { + var id: String + var source: String + var name: String + var targetAddress: String + var hostAddress: String? + var windowAddress: String? + var geometry: LKGestureCaptureGeometry + var geometryKind: String + var shapeKind: String? + + var usesEstimatedBounds: Bool { + geometryKind != "contentShapeBounds" + } +} + +/// Product rules in logical points, selected from the inspected device, not the Mac host. +enum LKSuggestionPlatform: String, Codable { + case iPhone, mac, unsupported + + var minimumTargetSize: Double? { + switch self { + case .iPhone: LookinMinimumIPhoneTargetSize + case .mac: LookinMinimumMacTargetSize + case .unsupported: nil + } + } + + static func resolve(deviceType: Int, model: String, deviceName: String, os: String) -> Self { + if deviceType == 3 || deviceType == 4 { + return .mac + } + let model = model.lowercased() + if model.hasPrefix("iphone") { + return .iPhone + } + if deviceType == 1 || model.hasPrefix("ipad") { + return .unsupported + } + if deviceType == 0 { + return deviceName.lowercased().hasPrefix("iphone") ? .iPhone : .unsupported + } + if os.lowercased().hasPrefix("macos") { + return .mac + } + // The legacy Others device enum represents a physical iPhone. + return deviceType == 2 && (model.isEmpty || model.hasPrefix("iphone")) ? .iPhone : .unsupported + } +} + +struct LKSuggestion: Identifiable { + let ruleID = "interaction.minimum-target-size" + let target: LKInteractionTarget + let minimumSize: Double + var id: String { + "\(ruleID):\(target.id)" + } + + var title: String { + target.usesEstimatedBounds ? "Potentially small hit target" : "Small hit target" + } +} + +enum LKHitTargetSuggestionRule { + static func evaluate(_ targets: [LKInteractionTarget], platform: LKSuggestionPlatform) -> [LKSuggestion] { + guard let minimum = platform.minimumTargetSize else { return [] } + var seen: Set = [] + return targets.compactMap { target in + guard let width = target.geometry.width, let height = target.geometry.height, + LookinHitTargetSizeDeficit(width, height, minimum) > 0, + seen.insert(target.id).inserted else { return nil } + return LKSuggestion(target: target, minimumSize: minimum) + }.sorted { $0.id < $1.id } + } +} + +/// Replaced by each current-page sample. Historical events never revive warnings. +struct LKSuggestionReport { + private(set) var warnings: [LKSuggestion] = [] + private(set) var inspectedCount = 0 + private(set) var coverageMessage = "Start capture to inspect the current page." + + mutating func replace(with snapshot: LKInteractionSnapshot?, platform: LKSuggestionPlatform, isCapturing: Bool) { + warnings = [] + inspectedCount = 0 + guard isCapturing else { + coverageMessage = "Start capture to inspect the current page." + return + } + guard platform != .unsupported else { + coverageMessage = "Target-size suggestions currently support iPhone and Mac targets." + return + } + guard let snapshot else { + coverageMessage = "Waiting for current interaction regions. Older Servers need an update to provide Suggestions." + return + } + warnings = LKHitTargetSuggestionRule.evaluate(snapshot.targets, platform: platform) + inspectedCount = snapshot.targets.count + var notes = ["Checks reported interaction regions; custom hit testing and occlusion may change the effective target."] + if snapshot.swiftUIStatus != "available" { + notes.append(snapshot.swiftUIStatus == "partial" + ? "Some SwiftUI hosts could not be inspected." + : "SwiftUI whole-page regions are unavailable on this runtime.") + } + if snapshot.nativeStatus != "available" { + notes.append("Native controls are not inspected on this platform.") + } + if snapshot.truncated { + notes.append("The region limit was reached; this page is only partially inspected.") + } + coverageMessage = notes.joined(separator: " ") + } +} diff --git a/LookInside/GestureDebug/LKSuggestionsView.swift b/LookInside/GestureDebug/LKSuggestionsView.swift new file mode 100644 index 00000000..bf9f3426 --- /dev/null +++ b/LookInside/GestureDebug/LKSuggestionsView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct LKSuggestionsView: View { + let report: LKSuggestionReport + let platform: LKSuggestionPlatform + @State private var selectedID: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("\(report.warnings.count) warnings", systemImage: "exclamationmark.triangle") + .foregroundStyle(report.warnings.isEmpty ? Color.secondary : .orange) + .font(.headline) + Spacer() + if let minimum = platform.minimumTargetSize { + Text("\(platform == .iPhone ? "iPhone" : "Mac") · \(minimum, specifier: "%.0f") × \(minimum, specifier: "%.0f") pt minimum") + .font(.callout) + } + } + Text("\(report.inspectedCount) current interaction regions inspected. \(report.coverageMessage)") + .font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + if report.warnings.isEmpty { + Text(report.inspectedCount == 0 + ? "No interaction regions are currently available for this check." + : "No small targets found in the inspected regions.") + .foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + HSplitView { + List(report.warnings, selection: $selectedID) { warning in + VStack(alignment: .leading, spacing: 4) { + Label(warning.title, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange).font(.callout) + Text(warning.target.name).lineLimit(2).font(.caption) + size(warning.target).font(.caption).monospacedDigit() + } + .padding(.vertical, 4).tag(warning.id) + } + .accessibilityIdentifier("gesture.suggestions.list") + .frame(minWidth: 240, idealWidth: 300) + if let warning = report.warnings.first(where: { $0.id == selectedID }) { + detail(warning).frame(minWidth: 250) + } else { + Text("Select a warning to inspect its target.") + .foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + .padding(14) + .onChange(of: report.warnings.map(\.id)) { ids in + if let selectedID, !ids.contains(selectedID) { + self.selectedID = nil + } + } + } + + private func size(_ target: LKInteractionTarget) -> some View { + Text("\(target.geometry.width ?? 0, specifier: "%.1f") × \(target.geometry.height ?? 0, specifier: "%.1f") pt") + } + + private func detail(_ warning: LKSuggestion) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(warning.title).font(.headline) + Text(warning.target.name).font(.system(.callout, design: .monospaced)) + size(warning.target).font(.title2).monospacedDigit() + Text("The width or height is below \(warning.minimumSize, specifier: "%.0f") pt. Increase the interactive area to at least \(warning.minimumSize, specifier: "%.0f") × \(warning.minimumSize, specifier: "%.0f") pt.") + if warning.target.usesEstimatedBounds { + Label("Estimated bounds", systemImage: "info.circle") + Text("These bounds may differ from the effective hit target. Check custom hit testing before changing the control.") + .foregroundStyle(.secondary) + } else { + Text("Measured from the reported \(warning.target.shapeKind ?? "interaction") content shape and its bounds.") + .foregroundStyle(.secondary) + } + Text("For SwiftUI, apply padding before the interaction contentShape / gesture. Padding outside a Button may only change layout.") + Divider() + Text("Target: \(warning.target.targetAddress)") + if let host = warning.target.hostAddress { + Text("Host: \(host)") + } + if let window = warning.target.windowAddress { + Text("Window: \(window)") + } + Text("Source: \(warning.target.source) · \(warning.target.geometryKind)") + Text("Origin: (\(warning.target.geometry.x ?? 0, specifier: "%.1f"), \(warning.target.geometry.y ?? 0, specifier: "%.1f")) pt · \(warning.target.geometry.coordinateSpace)") + } + .font(.callout).textSelection(.enabled).padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Scripts/test-gesture-models.sh b/Scripts/test-gesture-models.sh index 1e442fa9..b6bf0bfa 100755 --- a/Scripts/test-gesture-models.sh +++ b/Scripts/test-gesture-models.sh @@ -4,6 +4,8 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" GESTURE_TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lookinside-gesture-models.XXXXXX")" trap 'rm -rf "$GESTURE_TEST_DIR"' EXIT -swiftc -parse-as-library "$ROOT/LookInside/GestureDebug/LKGestureCaptureModels.swift" \ +swiftc -parse-as-library -import-objc-header "$ROOT/Sources/LookinCore/LookinHitTargetSize.h" \ + "$ROOT/LookInside/GestureDebug/LKGestureCaptureModels.swift" \ + "$ROOT/LookInside/GestureDebug/LKSuggestions.swift" \ "$ROOT/Tests/GestureDebug/LKGestureCaptureModelsTests.swift" -o "$GESTURE_TEST_DIR/gesture-models" "$GESTURE_TEST_DIR/gesture-models" diff --git a/Sources/LookinCore/LookinHitTargetSize.h b/Sources/LookinCore/LookinHitTargetSize.h new file mode 100644 index 00000000..2254e029 --- /dev/null +++ b/Sources/LookinCore/LookinHitTargetSize.h @@ -0,0 +1,23 @@ +#ifndef LookinHitTargetSize_h +#define LookinHitTargetSize_h + +#include + +static const double LookinMinimumIPhoneTargetSize = 44.0; +static const double LookinMinimumMacTargetSize = 28.0; + +/// Shared by Suggestions and target-app overlays. Zero means no size warning; +/// missing, invalid, or unsupported measurements are never reported as failures. +static inline double LookinHitTargetSizeDeficit(double width, double height, double minimum) { + if (!isfinite(width) || !isfinite(height) || !isfinite(minimum) || + width <= 0 || height <= 0 || minimum <= 0 || width > 100000 || height > 100000) { + return 0; + } + // Window conversion can turn 44 pt into 43.99999999999994 pt. + if (width >= minimum - 0.000001 && height >= minimum - 0.000001) { + return 0; + } + return 1.0 - fmin(width, height) / minimum; +} + +#endif diff --git a/Sources/LookinCore/include/LookinCore.h b/Sources/LookinCore/include/LookinCore.h index 1c5b1da3..ceada143 100644 --- a/Sources/LookinCore/include/LookinCore.h +++ b/Sources/LookinCore/include/LookinCore.h @@ -3,6 +3,7 @@ #endif #import "../LookinDefines.h" +#import "../LookinHitTargetSize.h" #import "../LookinAppInfo.h" #import "../LookinAttribute.h" #import "../LookinAttributesGroup.h" diff --git a/Tests/GestureDebug/LKGestureCaptureModelsTests.swift b/Tests/GestureDebug/LKGestureCaptureModelsTests.swift index 36ab12d3..37c73feb 100644 --- a/Tests/GestureDebug/LKGestureCaptureModelsTests.swift +++ b/Tests/GestureDebug/LKGestureCaptureModelsTests.swift @@ -7,6 +7,7 @@ struct LKGestureCaptureModelsTests { let decoder = JSONDecoder() var batch = try decoder.decode(LKGestureCaptureBatch.self, from: legacy) precondition(batch.nativeRegions == nil, "Old Servers remain compatible") + precondition(batch.interactions == nil) batch.nativeRegions = [LKNativeInteractionRegion( id: "0x1", source: "listSelection", viewClass: "SwiftUI.ListCollectionViewCell", viewAddress: "0x1", windowAddress: "0x2", label: "List row 0:1", handlers: [LKNativeInteractionHandler(kind: "listSelection", name: "selectionBehavior.onSelect")], @@ -22,6 +23,69 @@ struct LKGestureCaptureModelsTests { batch.nativeRegions = [] let cleared = try decoder.decode(LKGestureCaptureBatch.self, from: JSONEncoder().encode(batch)) precondition(cleared.nativeRegions?.isEmpty == true) + try testSuggestions() print("Gesture capture model compatibility and native archive tests passed") } + + static func testSuggestions() throws { + func target(_ width: Double, _ height: Double, id: String = "target", kind: String = "contentShapeBounds") -> LKInteractionTarget { + LKInteractionTarget(id: id, source: "swiftUI", name: "Test", targetAddress: "0x1", + geometry: LKGestureCaptureGeometry(x: 0, y: 0, width: width, height: height, + source: kind, coordinateSpace: "window"), + geometryKind: kind, shapeKind: "rectangle") + } + for (platform, threshold) in [(LKSuggestionPlatform.iPhone, 44.0), (.mac, 28.0)] { + for (width, height, warning) in [(threshold, threshold, false), (threshold - 0.1, threshold, true), + (threshold.nextDown, threshold, false), (threshold, threshold.nextDown, false), + (threshold - 0.0001, threshold, true), + (threshold, threshold - 0.1, true), (80, threshold - 1, true), + (100, 100, false), (0, 20, false), (-1, 20, false), + (Double.nan, 20, false), (20, Double.infinity, false)] + { + precondition(LKHitTargetSuggestionRule.evaluate([target(width, height)], platform: platform).isEmpty != warning, + "\(platform) \(width) x \(height)") + } + } + precondition(LKHitTargetSuggestionRule.evaluate([target(40, 40)], platform: .iPhone).count == 1) + precondition(LKHitTargetSuggestionRule.evaluate([target(44, 43.99999999999994)], platform: .iPhone).isEmpty, + "Real fixture geometry after window-coordinate conversion must not warn at 44 pt") + precondition(LKHitTargetSuggestionRule.evaluate([target(40, 40)], platform: .mac).isEmpty) + precondition(LKHitTargetSuggestionRule.evaluate([target(20, 20)], platform: .unsupported).isEmpty) + precondition(LKHitTargetSuggestionRule.evaluate([target(20, 20), target(20, 20)], platform: .iPhone).count == 1) + precondition(LKHitTargetSuggestionRule.evaluate([target(20, 20, kind: "nativeViewBounds")], platform: .iPhone).first?.target.usesEstimatedBounds == true) + precondition(LKHitTargetSuggestionRule.evaluate([target(20, 20)], platform: .iPhone).first?.target.usesEstimatedBounds == false) + precondition(LKSuggestionPlatform.resolve(deviceType: 0, model: "iPhone17,3", deviceName: "Test", os: "iOS 18.5") == .iPhone) + precondition(LKSuggestionPlatform.resolve(deviceType: 0, model: "", deviceName: "iPhone 16 Pro", os: "iOS 18.5") == .iPhone) + precondition(LKSuggestionPlatform.resolve(deviceType: 4, model: "Mac16,12", deviceName: "Mac", os: "iOS") == .mac) + precondition(LKSuggestionPlatform.resolve(deviceType: 3, model: "", deviceName: "Test", os: "") == .mac) + precondition(LKSuggestionPlatform.resolve(deviceType: 0, model: "iPad16,1", deviceName: "iPad", os: "iOS") == .unsupported) + precondition(LKSuggestionPlatform.resolve(deviceType: 0, model: "", deviceName: "Test", os: "iOS") == .unsupported) + + let snapshot = LKInteractionSnapshot(targets: [target(24, 24)], swiftUIStatus: "available", nativeStatus: "available", truncated: false) + var report = LKSuggestionReport() + report.replace(with: snapshot, platform: .iPhone, isCapturing: true) + let id = report.warnings.first?.id + for _ in 0 ..< 3 { + report.replace(with: snapshot, platform: .iPhone, isCapturing: true) + } + precondition(report.warnings.count == 1 && report.warnings.first?.id == id, "Heartbeats replace warnings without duplicates") + var nextPage = snapshot + nextPage.targets = [target(60, 60)] + report.replace(with: nextPage, platform: .iPhone, isCapturing: true) + precondition(report.warnings.isEmpty, "Page changes remove old warnings") + report.replace(with: snapshot, platform: .iPhone, isCapturing: false) + precondition(report.warnings.isEmpty && report.inspectedCount == 0, "Stop and disconnect clear current warnings") + report.replace(with: nil, platform: .iPhone, isCapturing: true) + precondition(report.warnings.isEmpty && report.coverageMessage.contains("Older Servers")) + nextPage.swiftUIStatus = "unavailable" + nextPage.nativeStatus = "unavailable" + nextPage.truncated = true + report.replace(with: nextPage, platform: .mac, isCapturing: true) + precondition(report.coverageMessage.contains("unavailable") && report.coverageMessage.contains("partially")) + let archive = LKGestureCaptureArchive(appName: "Test", bundleIdentifier: "test", snapshots: [], records: [], + interactions: snapshot, suggestionPlatform: .iPhone) + let decoded = try JSONDecoder().decode(LKGestureCaptureArchive.self, from: JSONEncoder().encode(archive)) + precondition(decoded.interactions?.targets.first?.geometry.width == 24 && decoded.suggestionPlatform == .iPhone) + print("Hit target rule boundaries, device selection, lifecycle and export tests passed") + } }