Skip to content

Commit 4a5eb6c

Browse files
Address PR review: drop stray print, add fallback tests, clarify docs
- Remove the unsuppressible `print()` warning in `resolvedAccessibilityElements()`. The count/returned-element mismatch it logged is a benign, already-handled case (the `as? NSObject` cast intentionally tolerates nil/non-castable indices), so it only added noise to test output. Replaced with an explanatory comment. - Add unit coverage for the new index-API fallback: - `testNonViewContainerResolvesElementsViaIndexAPIs`: a non-`UIView` `NSObject` that vends elements via `accessibilityElement(at:)` is resolved. - `testNonViewContainerWithNSNotFoundCountIsTreatedAsEmpty`: the `NSNotFound` sentinel is treated as empty (would hang ~NSIntegerMax iterations without the guard, so this doubles as a regression backstop). - `testViewContainerIgnoresIndexAPIsInFavorOfSubviews`: pins the current `!(self is UIView)` scoping contract. - `testSegmentedControlParsesAsSeries`: end-to-end guard that a real `UISegmentedControl` still parses as a three-element "N of 3" series. - Rewrite the `resolvedAccessibilityElements()` doc comment. The prior rationale ("UIKit synthesizes counts from the subview tree when shouldGroupAccessibilityChildren is set") is empirically false — plain/grouped views return `NSNotFound`. The comment now states the real purpose: the fallback exists for non-`UIView` `NSObject`s, and the `UIView` exclusion is a conservative scoping choice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b991cfc commit 4a5eb6c

2 files changed

Lines changed: 174 additions & 14 deletions

File tree

‎Example/UnitTests/AccessibilityHierarchyParserTests.swift‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,115 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
10921092
XCTAssertEqual(elements, ["B1", "A1"])
10931093
}
10941094

1095+
// MARK: - UIAccessibilityContainer Index API Fallback
1096+
1097+
/// A non-`UIView` `NSObject` that vends elements via the index-based container APIs
1098+
/// (`accessibilityElementCount()` / `accessibilityElement(at:)`) while leaving
1099+
/// `accessibilityElements` nil should have its elements resolved through that fallback.
1100+
func testNonViewContainerResolvesElementsViaIndexAPIs() {
1101+
let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
1102+
1103+
let child1 = UIAccessibilityElement(accessibilityContainer: rootView)
1104+
child1.accessibilityLabel = "Index Child 1"
1105+
child1.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
1106+
1107+
let child2 = UIAccessibilityElement(accessibilityContainer: rootView)
1108+
child2.accessibilityLabel = "Index Child 2"
1109+
child2.accessibilityFrame = CGRect(x: 0, y: 50, width: 200, height: 40)
1110+
1111+
// The container leaves `accessibilityElements` nil and only implements the index APIs.
1112+
let container = IndexAPIContainer(elements: [child1, child2])
1113+
rootView.accessibilityElements = [container]
1114+
1115+
let parser = AccessibilityHierarchyParser()
1116+
let elements = parser.parseAccessibilityHierarchy(
1117+
in: rootView,
1118+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
1119+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
1120+
).flattenToElements().map { $0.description }
1121+
1122+
XCTAssertEqual(elements, ["Index Child 1", "Index Child 2"])
1123+
}
1124+
1125+
/// `accessibilityElementCount()` returns `NSNotFound` (== `NSIntegerMax`) for objects that
1126+
/// don't actually implement the dynamic container methods. The parser must treat that sentinel
1127+
/// as "no elements" rather than iterating ~`NSIntegerMax` times. This test would hang if the
1128+
/// guard were removed, so it doubles as a regression test for the sentinel handling.
1129+
func testNonViewContainerWithNSNotFoundCountIsTreatedAsEmpty() {
1130+
let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
1131+
1132+
let child = UIAccessibilityElement(accessibilityContainer: rootView)
1133+
child.accessibilityLabel = "Should Not Appear"
1134+
child.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
1135+
1136+
// Report the `NSNotFound` sentinel even though an element technically exists.
1137+
let container = IndexAPIContainer(elements: [child], reportedCount: NSNotFound)
1138+
rootView.accessibilityElements = [container]
1139+
1140+
let parser = AccessibilityHierarchyParser()
1141+
let elements = parser.parseAccessibilityHierarchy(
1142+
in: rootView,
1143+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
1144+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
1145+
).flattenToElements().map { $0.description }
1146+
1147+
XCTAssertTrue(elements.isEmpty, "Expected the NSNotFound count to be treated as no elements, got: \(elements)")
1148+
}
1149+
1150+
/// A `UIView` with nil `accessibilityElements` that vends elements only through the index APIs
1151+
/// must still use the subview-iteration branch. This is the single shape that actually reaches
1152+
/// the `!(self is UIView)` scoping guard: standard UIKit views either populate
1153+
/// `accessibilityElements` (e.g. `UISegmentedControl`, handled by the first check) or report
1154+
/// `NSNotFound` (handled by the sentinel guard), so the guard is otherwise defensive. The
1155+
/// branches are mutually exclusive — without the guard, the index APIs would route this view
1156+
/// into the explicit-elements branch and the subview-iteration branch would be skipped entirely,
1157+
/// surfacing the phantom element in place of the real subview. This test pins that contract so
1158+
/// the guard isn't dropped as "redundant" in a future refactor.
1159+
func testViewContainerIgnoresIndexAPIsInFavorOfSubviews() {
1160+
let rootView = IndexAPIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
1161+
1162+
let realSubview = UIView(frame: .init(x: 0, y: 0, width: 200, height: 40))
1163+
realSubview.isAccessibilityElement = true
1164+
realSubview.accessibilityLabel = "Real Subview"
1165+
realSubview.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
1166+
rootView.addSubview(realSubview)
1167+
1168+
let parser = AccessibilityHierarchyParser()
1169+
let elements = parser.parseAccessibilityHierarchy(
1170+
in: rootView,
1171+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
1172+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
1173+
).flattenToElements().map { $0.description }
1174+
1175+
XCTAssertEqual(elements, ["Real Subview"])
1176+
XCTAssertFalse(elements.contains("Phantom Element"), "View containers must not resolve elements via the index APIs.")
1177+
}
1178+
1179+
/// End-to-end regression guard for a real `UISegmentedControl`. It vends its segments through
1180+
/// `accessibilityElements` (resolved by the first branch of `resolvedAccessibilityElements()`),
1181+
/// so it must continue to parse as a three-element series with VoiceOver-style "N of 3" context —
1182+
/// the `accessibilityElements` path must not be disturbed by the index-API fallback or its
1183+
/// `UIView` scoping.
1184+
func testSegmentedControlParsesAsSeries() {
1185+
let window = UIWindow(frame: .init(x: 0, y: 0, width: 320, height: 200))
1186+
let segmented = UISegmentedControl(items: ["One", "Two", "Three"])
1187+
segmented.frame = .init(x: 0, y: 0, width: 320, height: 40)
1188+
window.addSubview(segmented)
1189+
window.makeKeyAndVisible()
1190+
window.layoutIfNeeded()
1191+
1192+
let markers = parseMarkers(in: window)
1193+
1194+
XCTAssertEqual(markers.map { $0.label }, ["One", "Two", "Three"])
1195+
XCTAssertEqual(markers.map { $0.description }, [
1196+
"One. Button. 1 of 3.",
1197+
"Two. Button. 2 of 3.",
1198+
"Three. Button. 3 of 3.",
1199+
])
1200+
1201+
window.isHidden = true
1202+
}
1203+
10951204
// MARK: - Private Helpers
10961205

10971206
private func parseMarkers(in view: UIView) -> [AccessibilityMarker] {
@@ -1133,6 +1242,56 @@ private struct TestUserInterfaceLayoutDirectionProvider: UserInterfaceLayoutDire
11331242
var userInterfaceLayoutDirection: UIUserInterfaceLayoutDirection
11341243
}
11351244

1245+
// MARK: - UIAccessibilityContainer Index API Test Helpers
1246+
1247+
/// A non-`UIView` `NSObject` that acts as a `UIAccessibilityContainer` purely through the
1248+
/// index-based APIs, leaving `accessibilityElements` nil. `reportedCount` can diverge from the
1249+
/// backing array to exercise sentinel handling such as `NSNotFound`.
1250+
private final class IndexAPIContainer: NSObject {
1251+
private let elements: [Any]
1252+
private let reportedCount: Int
1253+
1254+
init(elements: [Any], reportedCount: Int? = nil) {
1255+
self.elements = elements
1256+
self.reportedCount = reportedCount ?? elements.count
1257+
super.init()
1258+
}
1259+
1260+
override func accessibilityElementCount() -> Int {
1261+
return reportedCount
1262+
}
1263+
1264+
override func accessibilityElement(at index: Int) -> Any? {
1265+
guard index >= 0, index < elements.count else {
1266+
return nil
1267+
}
1268+
return elements[index]
1269+
}
1270+
}
1271+
1272+
/// A `UIView` subclass with nil `accessibilityElements` that vends a uniquely-labelled "phantom"
1273+
/// element through the index-based container APIs. This is the only view shape that reaches the
1274+
/// `!(self is UIView)` guard: UIKit controls that vend internal elements (e.g. `UISegmentedControl`)
1275+
/// do so via `accessibilityElements`, which is handled before the guard, and plain views report
1276+
/// `NSNotFound`, which the sentinel guard handles. The distinct phantom label makes the regression
1277+
/// observable: if the parser ever resolved views through the index APIs, "Phantom Element" would
1278+
/// surface in place of the real subview.
1279+
private final class IndexAPIView: UIView {
1280+
private lazy var phantomElement: UIAccessibilityElement = {
1281+
let element = UIAccessibilityElement(accessibilityContainer: self)
1282+
element.accessibilityLabel = "Phantom Element"
1283+
return element
1284+
}()
1285+
1286+
override func accessibilityElementCount() -> Int {
1287+
return 1
1288+
}
1289+
1290+
override func accessibilityElement(at index: Int) -> Any? {
1291+
return index == 0 ? phantomElement : nil
1292+
}
1293+
}
1294+
11361295
private struct TestUserInterfaceIdiomProvider: UserInterfaceIdiomProviding {
11371296
var userInterfaceIdiom: UIUserInterfaceIdiom
11381297
}

‎Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -781,14 +781,18 @@ private extension NSObject {
781781
}
782782

783783
/// Returns the explicit accessibility elements exposed by this object. When
784-
/// `accessibilityElements` is set (including to an empty array) it is used directly. When it is
785-
/// `nil` and the receiver is not a `UIView`, this falls back to the `accessibilityElementCount()` /
786-
/// `accessibilityElement(at:)` container APIs, mirroring how `UIAccessibilityContainer` consumers
787-
/// (including VoiceOver) resolve elements. For `UIView` instances the existing subview-iteration
788-
/// path is used instead — UIKit synthesizes counts from the subview tree when flags like
789-
/// `shouldGroupAccessibilityChildren` are set, which would otherwise duplicate or recurse on
790-
/// elements already covered by subview parsing. Returns `nil` when the object does not act as an
791-
/// explicit accessibility container.
784+
/// `accessibilityElements` is set (including to an empty array) it is used directly — this covers
785+
/// UIKit containers such as `UISegmentedControl`, which populate `accessibilityElements` with
786+
/// their internal elements. When it is `nil` and the receiver is not a `UIView`, this falls back
787+
/// to the `accessibilityElementCount()` / `accessibilityElement(at:)` container APIs, mirroring
788+
/// how `UIAccessibilityContainer` consumers (including VoiceOver) resolve elements. This fallback
789+
/// is the case the method exists for: non-`UIView` `NSObject`s have no subviews to traverse, so
790+
/// without it their index-vended elements would be invisible to the parser. `UIView`s are
791+
/// deliberately excluded from the fallback so their existing subview-traversal path (with its
792+
/// grouping, sorting, and pruning) is preserved; in practice no standard UIKit view needs it
793+
/// regardless, since they either populate `accessibilityElements` or report `NSNotFound` from
794+
/// `accessibilityElementCount()`. Returns `nil` when the object does not act as an explicit
795+
/// accessibility container.
792796
private func resolvedAccessibilityElements() -> [NSObject]? {
793797
if let elements = accessibilityElements as? [NSObject] {
794798
return elements
@@ -811,16 +815,13 @@ private extension NSObject {
811815
var elements: [NSObject] = []
812816
elements.reserveCapacity(count)
813817
for index in 0 ..< count {
818+
// `accessibilityElement(at:)` may return `nil` (or a non-`NSObject`) for individual indices even
819+
// when `accessibilityElementCount()` reports a larger count; those are skipped rather than treated
820+
// as a hard error, mirroring how a consumer iterating the container would tolerate gaps.
814821
if let element = accessibilityElement(at: index) as? NSObject {
815822
elements.append(element)
816823
}
817824
}
818-
if elements.count != count {
819-
print(
820-
"[AccessibilitySnapshot] Warning: \(type(of: self)) reported accessibilityElementCount() == "
821-
+ "\(count) but only \(elements.count) element(s) were returned by accessibilityElement(at:)."
822-
)
823-
}
824825
return elements.isEmpty ? nil : elements
825826
}
826827

0 commit comments

Comments
 (0)