|
| 1 | +import XCTest |
| 2 | + |
| 3 | +extension RunnerTests { |
| 4 | + func privateAXPresentation(rawRoot: [String: Any], options: SnapshotOptions, viewport: CGRect) |
| 5 | + -> [SnapshotNode] |
| 6 | + { |
| 7 | + var nodes: [SnapshotNode] = [] |
| 8 | + var hints: [Int: (above: Bool, below: Bool)] = [:] |
| 9 | + appendPrivateAXNode(rawRoot, to: &nodes, hints: &hints, options: options, viewport: viewport, |
| 10 | + depth: 0, parentIndex: nil, insideMatchedScope: false, scrollContext: nil) |
| 11 | + guard !hints.isEmpty else { return nodes } |
| 12 | + return nodes.map { node in |
| 13 | + guard let hint = hints[node.index] else { return node } |
| 14 | + return SnapshotNode(index: node.index, type: node.type, label: node.label, |
| 15 | + identifier: node.identifier, value: node.value, rect: node.rect, enabled: node.enabled, |
| 16 | + focused: node.focused, selected: node.selected, hittable: node.hittable, depth: node.depth, |
| 17 | + parentIndex: node.parentIndex, hiddenContentAbove: hint.above ? true : node.hiddenContentAbove, |
| 18 | + hiddenContentBelow: hint.below ? true : node.hiddenContentBelow, actions: node.actions) |
| 19 | + } |
| 20 | + } |
| 21 | + |
| 22 | + private func appendPrivateAXNode(_ raw: [String: Any], to nodes: inout [SnapshotNode], |
| 23 | + hints: inout [Int: (above: Bool, below: Bool)], options: SnapshotOptions, viewport: CGRect, |
| 24 | + depth: Int, parentIndex: Int?, insideMatchedScope: Bool, |
| 25 | + scrollContext: (index: Int, rect: CGRect)?) |
| 26 | + { |
| 27 | + if let limit = options.depth, depth > limit { return } |
| 28 | + let rect = privateAXRect(raw["frame"]) |
| 29 | + let label = privateAXPresentationString(raw["label"]) |
| 30 | + let identifier = privateAXPresentationString(raw["identifier"]) |
| 31 | + let value = privateAXPresentationString(raw["value"]) |
| 32 | + let rawType = privateAXPresentationInt(raw["type"]) ?? 0 |
| 33 | + let enabled = privateAXPresentationBool(raw["enabled"]) ?? true |
| 34 | + let hasFrame = !rect.isNull && !rect.isEmpty |
| 35 | + let visible = (!hasFrame || isVisibleInViewport(rect, viewport)) |
| 36 | + && (!hasFrame || scrollContext.map { isVisibleInViewport(rect, $0.rect) } ?? true) |
| 37 | + let decision = flatSnapshotFilterDecision( |
| 38 | + FlatSnapshotFilterNode(isRoot: parentIndex == nil, label: label, identifier: identifier, |
| 39 | + valueText: value.isEmpty ? nil : value, visible: visible), |
| 40 | + options: options, insideMatchedScope: insideMatchedScope) |
| 41 | + let include = decision.include && (parentIndex == nil || visible) |
| 42 | + |
| 43 | + if !include, !visible, let scrollContext, hasFrame { |
| 44 | + var hint = hints[scrollContext.index] ?? (above: false, below: false) |
| 45 | + if rect.maxY <= scrollContext.rect.minY { hint.above = true } |
| 46 | + if rect.minY >= scrollContext.rect.maxY { hint.below = true } |
| 47 | + hints[scrollContext.index] = hint |
| 48 | + } |
| 49 | + |
| 50 | + let currentIndex: Int? |
| 51 | + if include { |
| 52 | + currentIndex = nodes.count |
| 53 | + let typeName = flatSnapshotElementType(rawElementType: rawType).map(elementTypeName) |
| 54 | + ?? "Element(\(rawType))" |
| 55 | + nodes.append(SnapshotNode(index: nodes.count, type: typeName, |
| 56 | + label: label.isEmpty ? nil : label, identifier: identifier.isEmpty ? nil : identifier, |
| 57 | + value: value.isEmpty ? nil : value, rect: snapshotRect(from: rect), enabled: enabled, |
| 58 | + focused: privateAXPresentationBool(raw["focused"]) == true ? true : nil, |
| 59 | + selected: privateAXPresentationBool(raw["selected"]) == true ? true : nil, |
| 60 | + hittable: hasFrame && visible && enabled && privateAXInteractiveCandidate(rawElementType: rawType), |
| 61 | + depth: depth, parentIndex: parentIndex, hiddenContentAbove: nil, hiddenContentBelow: nil, |
| 62 | + actions: raw["actions"] as? [String])) |
| 63 | + } else { currentIndex = parentIndex } |
| 64 | + |
| 65 | + let nextScrollContext: (index: Int, rect: CGRect)? |
| 66 | + if include, hasFrame, let type = flatSnapshotElementType(rawElementType: rawType), |
| 67 | + Self.scrollContainerTypes.contains(type), let currentIndex { |
| 68 | + nextScrollContext = (currentIndex, rect) |
| 69 | + } else { nextScrollContext = scrollContext } |
| 70 | + for child in raw["children"] as? [[String: Any]] ?? [] { |
| 71 | + appendPrivateAXNode(child, to: &nodes, hints: &hints, options: options, viewport: viewport, |
| 72 | + depth: depth + 1, parentIndex: currentIndex, |
| 73 | + insideMatchedScope: decision.insideMatchedScope, scrollContext: nextScrollContext) |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + func appendPrivateAXNode(_ raw: [String: Any], to nodes: inout [SnapshotNode], |
| 78 | + options: SnapshotOptions, viewport: CGRect, depth: Int, parentIndex: Int?, |
| 79 | + insideMatchedScope: Bool) |
| 80 | + { |
| 81 | + var hints: [Int: (above: Bool, below: Bool)] = [:] |
| 82 | + appendPrivateAXNode(raw, to: &nodes, hints: &hints, options: options, viewport: viewport, |
| 83 | + depth: depth, parentIndex: parentIndex, insideMatchedScope: insideMatchedScope, |
| 84 | + scrollContext: nil) |
| 85 | + } |
| 86 | + |
| 87 | + private func privateAXPresentationString(_ value: Any?) -> String { |
| 88 | + guard let value else { return "" } |
| 89 | + return (value as? String ?? String(describing: value)) |
| 90 | + .trimmingCharacters(in: .whitespacesAndNewlines) |
| 91 | + } |
| 92 | + private func privateAXPresentationInt(_ value: Any?) -> Int? { |
| 93 | + (value as? Int) ?? (value as? NSNumber)?.intValue |
| 94 | + } |
| 95 | + private func privateAXPresentationBool(_ value: Any?) -> Bool? { |
| 96 | + (value as? Bool) ?? (value as? NSNumber)?.boolValue |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +#if AGENT_DEVICE_RUNNER_UNIT_TESTS |
| 101 | +extension RunnerTests { |
| 102 | + func testPrivateAXRegularPresentationProjectsToViewportAndKeepsScrollHint() { |
| 103 | + func frame(_ x: Double, _ y: Double, _ width: Double, _ height: Double) -> [String: Any] { |
| 104 | + ["x": x, "y": y, "width": width, "height": height] |
| 105 | + } |
| 106 | + let root: [String: Any] = ["type": Int(XCUIElement.ElementType.application.rawValue), |
| 107 | + "label": "Element", "frame": frame(0, 0, 402, 874), "children": [[ |
| 108 | + "type": Int(XCUIElement.ElementType.scrollView.rawValue), "frame": frame(0, 96, 402, 700), |
| 109 | + "children": [["type": Int(XCUIElement.ElementType.button.rawValue), "label": "Profile picture", "frame": frame(16, 120, 44, 44)], |
| 110 | + ["type": Int(XCUIElement.ElementType.button.rawValue), "label": "Theme", "frame": frame(16, 900, 360, 44)]]]]] |
| 111 | + let nodes = privateAXPresentation(rawRoot: root, |
| 112 | + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), |
| 113 | + viewport: CGRect(x: 0, y: 0, width: 402, height: 874)) |
| 114 | + XCTAssertEqual(nodes.compactMap(\.label), ["Element", "Profile picture"]) |
| 115 | + XCTAssertEqual(nodes.first { $0.type == "ScrollView" }?.hiddenContentBelow, true) |
| 116 | + } |
| 117 | + |
| 118 | + func testPrivateAXGeometrylessSemanticsAreNeverActionableOrScrollContexts() { |
| 119 | + let zero = ["x": 0, "y": 0, "width": 0, "height": 0] |
| 120 | + let root: [String: Any] = ["type": Int(XCUIElement.ElementType.application.rawValue), |
| 121 | + "label": "Element", "frame": ["x": 0, "y": 0, "width": 402, "height": 874], |
| 122 | + "children": [["type": Int(XCUIElement.ElementType.scrollView.rawValue), |
| 123 | + "label": "Settings semantics", "frame": zero, "children": [[ |
| 124 | + "type": Int(XCUIElement.ElementType.button.rawValue), "label": "Theme", "frame": zero]]]]] |
| 125 | + let nodes = privateAXPresentation(rawRoot: root, |
| 126 | + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), |
| 127 | + viewport: CGRect(x: 0, y: 0, width: 402, height: 874)) |
| 128 | + XCTAssertEqual(nodes.compactMap(\.label), ["Element", "Settings semantics", "Theme"]) |
| 129 | + XCTAssertEqual(nodes.filter { $0.index != 0 }.map(\.hittable), [false, false]) |
| 130 | + XCTAssertTrue(nodes.allSatisfy { $0.hiddenContentAbove == nil && $0.hiddenContentBelow == nil }) |
| 131 | + } |
| 132 | +} |
| 133 | +#endif |
0 commit comments