@@ -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+
10371196private struct TestUserInterfaceIdiomProvider : UserInterfaceIdiomProviding {
10381197 var userInterfaceIdiom : UIUserInterfaceIdiom
10391198}
0 commit comments