@@ -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+
11361295private struct TestUserInterfaceIdiomProvider : UserInterfaceIdiomProviding {
11371296 var userInterfaceIdiom : UIUserInterfaceIdiom
11381297}
0 commit comments