Skip to content

Commit f988545

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 79e36ed commit f988545

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
@@ -993,6 +993,115 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
993993
XCTAssertNotNil(listNode.container)
994994
}
995995

996+
// MARK: - UIAccessibilityContainer Index API Fallback
997+
998+
/// A non-`UIView` `NSObject` that vends elements via the index-based container APIs
999+
/// (`accessibilityElementCount()` / `accessibilityElement(at:)`) while leaving
1000+
/// `accessibilityElements` nil should have its elements resolved through that fallback.
1001+
func testNonViewContainerResolvesElementsViaIndexAPIs() {
1002+
let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
1003+
1004+
let child1 = UIAccessibilityElement(accessibilityContainer: rootView)
1005+
child1.accessibilityLabel = "Index Child 1"
1006+
child1.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
1007+
1008+
let child2 = UIAccessibilityElement(accessibilityContainer: rootView)
1009+
child2.accessibilityLabel = "Index Child 2"
1010+
child2.accessibilityFrame = CGRect(x: 0, y: 50, width: 200, height: 40)
1011+
1012+
// The container leaves `accessibilityElements` nil and only implements the index APIs.
1013+
let container = IndexAPIContainer(elements: [child1, child2])
1014+
rootView.accessibilityElements = [container]
1015+
1016+
let parser = AccessibilityHierarchyParser()
1017+
let elements = parser.parseAccessibilityHierarchy(
1018+
in: rootView,
1019+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
1020+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
1021+
).flattenToElements().map { $0.description }
1022+
1023+
XCTAssertEqual(elements, ["Index Child 1", "Index Child 2"])
1024+
}
1025+
1026+
/// `accessibilityElementCount()` returns `NSNotFound` (== `NSIntegerMax`) for objects that
1027+
/// don't actually implement the dynamic container methods. The parser must treat that sentinel
1028+
/// as "no elements" rather than iterating ~`NSIntegerMax` times. This test would hang if the
1029+
/// guard were removed, so it doubles as a regression test for the sentinel handling.
1030+
func testNonViewContainerWithNSNotFoundCountIsTreatedAsEmpty() {
1031+
let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
1032+
1033+
let child = UIAccessibilityElement(accessibilityContainer: rootView)
1034+
child.accessibilityLabel = "Should Not Appear"
1035+
child.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
1036+
1037+
// Report the `NSNotFound` sentinel even though an element technically exists.
1038+
let container = IndexAPIContainer(elements: [child], reportedCount: NSNotFound)
1039+
rootView.accessibilityElements = [container]
1040+
1041+
let parser = AccessibilityHierarchyParser()
1042+
let elements = parser.parseAccessibilityHierarchy(
1043+
in: rootView,
1044+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
1045+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
1046+
).flattenToElements().map { $0.description }
1047+
1048+
XCTAssertTrue(elements.isEmpty, "Expected the NSNotFound count to be treated as no elements, got: \(elements)")
1049+
}
1050+
1051+
/// A `UIView` with nil `accessibilityElements` that vends elements only through the index APIs
1052+
/// must still use the subview-iteration branch. This is the single shape that actually reaches
1053+
/// the `!(self is UIView)` scoping guard: standard UIKit views either populate
1054+
/// `accessibilityElements` (e.g. `UISegmentedControl`, handled by the first check) or report
1055+
/// `NSNotFound` (handled by the sentinel guard), so the guard is otherwise defensive. The
1056+
/// branches are mutually exclusive — without the guard, the index APIs would route this view
1057+
/// into the explicit-elements branch and the subview-iteration branch would be skipped entirely,
1058+
/// surfacing the phantom element in place of the real subview. This test pins that contract so
1059+
/// the guard isn't dropped as "redundant" in a future refactor.
1060+
func testViewContainerIgnoresIndexAPIsInFavorOfSubviews() {
1061+
let rootView = IndexAPIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
1062+
1063+
let realSubview = UIView(frame: .init(x: 0, y: 0, width: 200, height: 40))
1064+
realSubview.isAccessibilityElement = true
1065+
realSubview.accessibilityLabel = "Real Subview"
1066+
realSubview.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
1067+
rootView.addSubview(realSubview)
1068+
1069+
let parser = AccessibilityHierarchyParser()
1070+
let elements = parser.parseAccessibilityHierarchy(
1071+
in: rootView,
1072+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
1073+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
1074+
).flattenToElements().map { $0.description }
1075+
1076+
XCTAssertEqual(elements, ["Real Subview"])
1077+
XCTAssertFalse(elements.contains("Phantom Element"), "View containers must not resolve elements via the index APIs.")
1078+
}
1079+
1080+
/// End-to-end regression guard for a real `UISegmentedControl`. It vends its segments through
1081+
/// `accessibilityElements` (resolved by the first branch of `resolvedAccessibilityElements()`),
1082+
/// so it must continue to parse as a three-element series with VoiceOver-style "N of 3" context —
1083+
/// the `accessibilityElements` path must not be disturbed by the index-API fallback or its
1084+
/// `UIView` scoping.
1085+
func testSegmentedControlParsesAsSeries() {
1086+
let window = UIWindow(frame: .init(x: 0, y: 0, width: 320, height: 200))
1087+
let segmented = UISegmentedControl(items: ["One", "Two", "Three"])
1088+
segmented.frame = .init(x: 0, y: 0, width: 320, height: 40)
1089+
window.addSubview(segmented)
1090+
window.makeKeyAndVisible()
1091+
window.layoutIfNeeded()
1092+
1093+
let markers = parseMarkers(in: window)
1094+
1095+
XCTAssertEqual(markers.map { $0.label }, ["One", "Two", "Three"])
1096+
XCTAssertEqual(markers.map { $0.description }, [
1097+
"One. Button. 1 of 3.",
1098+
"Two. Button. 2 of 3.",
1099+
"Three. Button. 3 of 3.",
1100+
])
1101+
1102+
window.isHidden = true
1103+
}
1104+
9961105
// MARK: - Private Helpers
9971106

9981107
private func parseMarkers(in view: UIView) -> [AccessibilityMarker] {
@@ -1034,6 +1143,56 @@ private struct TestUserInterfaceLayoutDirectionProvider: UserInterfaceLayoutDire
10341143
var userInterfaceLayoutDirection: UIUserInterfaceLayoutDirection
10351144
}
10361145

1146+
// MARK: - UIAccessibilityContainer Index API Test Helpers
1147+
1148+
/// A non-`UIView` `NSObject` that acts as a `UIAccessibilityContainer` purely through the
1149+
/// index-based APIs, leaving `accessibilityElements` nil. `reportedCount` can diverge from the
1150+
/// backing array to exercise sentinel handling such as `NSNotFound`.
1151+
private final class IndexAPIContainer: NSObject {
1152+
private let elements: [Any]
1153+
private let reportedCount: Int
1154+
1155+
init(elements: [Any], reportedCount: Int? = nil) {
1156+
self.elements = elements
1157+
self.reportedCount = reportedCount ?? elements.count
1158+
super.init()
1159+
}
1160+
1161+
override func accessibilityElementCount() -> Int {
1162+
return reportedCount
1163+
}
1164+
1165+
override func accessibilityElement(at index: Int) -> Any? {
1166+
guard index >= 0, index < elements.count else {
1167+
return nil
1168+
}
1169+
return elements[index]
1170+
}
1171+
}
1172+
1173+
/// A `UIView` subclass with nil `accessibilityElements` that vends a uniquely-labelled "phantom"
1174+
/// element through the index-based container APIs. This is the only view shape that reaches the
1175+
/// `!(self is UIView)` guard: UIKit controls that vend internal elements (e.g. `UISegmentedControl`)
1176+
/// do so via `accessibilityElements`, which is handled before the guard, and plain views report
1177+
/// `NSNotFound`, which the sentinel guard handles. The distinct phantom label makes the regression
1178+
/// observable: if the parser ever resolved views through the index APIs, "Phantom Element" would
1179+
/// surface in place of the real subview.
1180+
private final class IndexAPIView: UIView {
1181+
private lazy var phantomElement: UIAccessibilityElement = {
1182+
let element = UIAccessibilityElement(accessibilityContainer: self)
1183+
element.accessibilityLabel = "Phantom Element"
1184+
return element
1185+
}()
1186+
1187+
override func accessibilityElementCount() -> Int {
1188+
return 1
1189+
}
1190+
1191+
override func accessibilityElement(at index: Int) -> Any? {
1192+
return index == 0 ? phantomElement : nil
1193+
}
1194+
}
1195+
10371196
private struct TestUserInterfaceIdiomProvider: UserInterfaceIdiomProviding {
10381197
var userInterfaceIdiom: UIUserInterfaceIdiom
10391198
}

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -885,14 +885,18 @@ private extension NSObject {
885885
}
886886

887887
/// Returns the explicit accessibility elements exposed by this object. When
888-
/// `accessibilityElements` is set (including to an empty array) it is used directly. When it is
889-
/// `nil` and the receiver is not a `UIView`, this falls back to the `accessibilityElementCount()` /
890-
/// `accessibilityElement(at:)` container APIs, mirroring how `UIAccessibilityContainer` consumers
891-
/// (including VoiceOver) resolve elements. For `UIView` instances the existing subview-iteration
892-
/// path is used instead — UIKit synthesizes counts from the subview tree when flags like
893-
/// `shouldGroupAccessibilityChildren` are set, which would otherwise duplicate or recurse on
894-
/// elements already covered by subview parsing. Returns `nil` when the object does not act as an
895-
/// explicit accessibility container.
888+
/// `accessibilityElements` is set (including to an empty array) it is used directly — this covers
889+
/// UIKit containers such as `UISegmentedControl`, which populate `accessibilityElements` with
890+
/// their internal elements. When it is `nil` and the receiver is not a `UIView`, this falls back
891+
/// to the `accessibilityElementCount()` / `accessibilityElement(at:)` container APIs, mirroring
892+
/// how `UIAccessibilityContainer` consumers (including VoiceOver) resolve elements. This fallback
893+
/// is the case the method exists for: non-`UIView` `NSObject`s have no subviews to traverse, so
894+
/// without it their index-vended elements would be invisible to the parser. `UIView`s are
895+
/// deliberately excluded from the fallback so their existing subview-traversal path (with its
896+
/// grouping, sorting, and pruning) is preserved; in practice no standard UIKit view needs it
897+
/// regardless, since they either populate `accessibilityElements` or report `NSNotFound` from
898+
/// `accessibilityElementCount()`. Returns `nil` when the object does not act as an explicit
899+
/// accessibility container.
896900
private func resolvedAccessibilityElements() -> [NSObject]? {
897901
if let elements = accessibilityElements as? [NSObject] {
898902
return elements
@@ -915,16 +919,13 @@ private extension NSObject {
915919
var elements: [NSObject] = []
916920
elements.reserveCapacity(count)
917921
for index in 0 ..< count {
922+
// `accessibilityElement(at:)` may return `nil` (or a non-`NSObject`) for individual indices even
923+
// when `accessibilityElementCount()` reports a larger count; those are skipped rather than treated
924+
// as a hard error, mirroring how a consumer iterating the container would tolerate gaps.
918925
if let element = accessibilityElement(at: index) as? NSObject {
919926
elements.append(element)
920927
}
921928
}
922-
if elements.count != count {
923-
print(
924-
"[AccessibilitySnapshot] Warning: \(type(of: self)) reported accessibilityElementCount() == "
925-
+ "\(count) but only \(elements.count) element(s) were returned by accessibilityElement(at:)."
926-
)
927-
}
928929
return elements.isEmpty ? nil : elements
929930
}
930931

0 commit comments

Comments
 (0)