Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions Example/UnitTests/AccessibilityHierarchyParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,115 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
XCTAssertEqual(elements, ["B1", "A1"])
}

// MARK: - UIAccessibilityContainer Index API Fallback

/// A non-`UIView` `NSObject` that vends elements via the index-based container APIs
/// (`accessibilityElementCount()` / `accessibilityElement(at:)`) while leaving
/// `accessibilityElements` nil should have its elements resolved through that fallback.
func testNonViewContainerResolvesElementsViaIndexAPIs() {
let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 100))

let child1 = UIAccessibilityElement(accessibilityContainer: rootView)
child1.accessibilityLabel = "Index Child 1"
child1.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)

let child2 = UIAccessibilityElement(accessibilityContainer: rootView)
child2.accessibilityLabel = "Index Child 2"
child2.accessibilityFrame = CGRect(x: 0, y: 50, width: 200, height: 40)

// The container leaves `accessibilityElements` nil and only implements the index APIs.
let container = IndexAPIContainer(elements: [child1, child2])
rootView.accessibilityElements = [container]

let parser = AccessibilityHierarchyParser()
let elements = parser.parseAccessibilityHierarchy(
in: rootView,
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
).flattenToElements().map { $0.description }

XCTAssertEqual(elements, ["Index Child 1", "Index Child 2"])
}

/// `accessibilityElementCount()` returns `NSNotFound` (== `NSIntegerMax`) for objects that
/// don't actually implement the dynamic container methods. The parser must treat that sentinel
/// as "no elements" rather than iterating ~`NSIntegerMax` times. This test would hang if the
/// guard were removed, so it doubles as a regression test for the sentinel handling.
func testNonViewContainerWithNSNotFoundCountIsTreatedAsEmpty() {
let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 100))

let child = UIAccessibilityElement(accessibilityContainer: rootView)
child.accessibilityLabel = "Should Not Appear"
child.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)

// Report the `NSNotFound` sentinel even though an element technically exists.
let container = IndexAPIContainer(elements: [child], reportedCount: NSNotFound)
rootView.accessibilityElements = [container]

let parser = AccessibilityHierarchyParser()
let elements = parser.parseAccessibilityHierarchy(
in: rootView,
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
).flattenToElements().map { $0.description }

XCTAssertTrue(elements.isEmpty, "Expected the NSNotFound count to be treated as no elements, got: \(elements)")
}

/// A `UIView` with nil `accessibilityElements` that vends elements only through the index APIs
/// must still use the subview-iteration branch. This is the single shape that actually reaches
/// the `!(self is UIView)` scoping guard: standard UIKit views either populate
/// `accessibilityElements` (e.g. `UISegmentedControl`, handled by the first check) or report
/// `NSNotFound` (handled by the sentinel guard), so the guard is otherwise defensive. The
/// branches are mutually exclusive — without the guard, the index APIs would route this view
/// into the explicit-elements branch and the subview-iteration branch would be skipped entirely,
/// surfacing the phantom element in place of the real subview. This test pins that contract so
/// the guard isn't dropped as "redundant" in a future refactor.
func testViewContainerIgnoresIndexAPIsInFavorOfSubviews() {
let rootView = IndexAPIView(frame: .init(x: 0, y: 0, width: 200, height: 100))

let realSubview = UIView(frame: .init(x: 0, y: 0, width: 200, height: 40))
realSubview.isAccessibilityElement = true
realSubview.accessibilityLabel = "Real Subview"
realSubview.accessibilityFrame = CGRect(x: 0, y: 0, width: 200, height: 40)
rootView.addSubview(realSubview)

let parser = AccessibilityHierarchyParser()
let elements = parser.parseAccessibilityHierarchy(
in: rootView,
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
).flattenToElements().map { $0.description }

XCTAssertEqual(elements, ["Real Subview"])
XCTAssertFalse(elements.contains("Phantom Element"), "View containers must not resolve elements via the index APIs.")
}

/// End-to-end regression guard for a real `UISegmentedControl`. It vends its segments through
/// `accessibilityElements` (resolved by the first branch of `resolvedAccessibilityElements()`),
/// so it must continue to parse as a three-element series with VoiceOver-style "N of 3" context —
/// the `accessibilityElements` path must not be disturbed by the index-API fallback or its
/// `UIView` scoping.
func testSegmentedControlParsesAsSeries() {
let window = UIWindow(frame: .init(x: 0, y: 0, width: 320, height: 200))
let segmented = UISegmentedControl(items: ["One", "Two", "Three"])
segmented.frame = .init(x: 0, y: 0, width: 320, height: 40)
window.addSubview(segmented)
window.makeKeyAndVisible()
window.layoutIfNeeded()

let markers = parseMarkers(in: window)

XCTAssertEqual(markers.map { $0.label }, ["One", "Two", "Three"])
XCTAssertEqual(markers.map { $0.description }, [
"One. Button. 1 of 3.",
"Two. Button. 2 of 3.",
"Three. Button. 3 of 3.",
])

window.isHidden = true
}

// MARK: - Private Helpers

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

// MARK: - UIAccessibilityContainer Index API Test Helpers

/// A non-`UIView` `NSObject` that acts as a `UIAccessibilityContainer` purely through the
/// index-based APIs, leaving `accessibilityElements` nil. `reportedCount` can diverge from the
/// backing array to exercise sentinel handling such as `NSNotFound`.
private final class IndexAPIContainer: NSObject {
private let elements: [Any]
private let reportedCount: Int

init(elements: [Any], reportedCount: Int? = nil) {
self.elements = elements
self.reportedCount = reportedCount ?? elements.count
super.init()
}

override func accessibilityElementCount() -> Int {
return reportedCount
}

override func accessibilityElement(at index: Int) -> Any? {
guard index >= 0, index < elements.count else {
return nil
}
return elements[index]
}
}

/// A `UIView` subclass with nil `accessibilityElements` that vends a uniquely-labelled "phantom"
/// element through the index-based container APIs. This is the only view shape that reaches the
/// `!(self is UIView)` guard: UIKit controls that vend internal elements (e.g. `UISegmentedControl`)
/// do so via `accessibilityElements`, which is handled before the guard, and plain views report
/// `NSNotFound`, which the sentinel guard handles. The distinct phantom label makes the regression
/// observable: if the parser ever resolved views through the index APIs, "Phantom Element" would
/// surface in place of the real subview.
private final class IndexAPIView: UIView {
private lazy var phantomElement: UIAccessibilityElement = {
let element = UIAccessibilityElement(accessibilityContainer: self)
element.accessibilityLabel = "Phantom Element"
return element
}()

override func accessibilityElementCount() -> Int {
return 1
}

override func accessibilityElement(at index: Int) -> Any? {
return index == 0 ? phantomElement : nil
}
}

private struct TestUserInterfaceIdiomProvider: UserInterfaceIdiomProviding {
var userInterfaceIdiom: UIUserInterfaceIdiom
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -715,9 +715,11 @@ private extension NSObject {
// _UIInheritedView inside _UIFloatingBarContainerView) use zero-frame wrappers whose children overflow and
// are visible. Pruning those hides real accessible content such as the UISearchBarTextField rendered by
// .searchable().
let explicitAccessibilityElements = resolvedAccessibilityElements()

if let `self` = self as? UIView,
self.isHidden || self.alpha <= 0
|| (self.frame.size == .zero && (self.clipsToBounds || self.isAccessibilityElement || self.accessibilityElements != nil))
|| (self.frame.size == .zero && (self.clipsToBounds || self.isAccessibilityElement || explicitAccessibilityElements != nil))
{
return []
}
Expand All @@ -727,7 +729,7 @@ private extension NSObject {
if isAccessibilityElement {
recursiveAccessibilityHierarchy.append(.element(self, contextProvider: contextProvider))

} else if let accessibilityElements = accessibilityElements as? [NSObject] {
} else if let accessibilityElements = explicitAccessibilityElements {
var accessibilityHierarchyOfElements: [AccessibilityNode] = []
for element in accessibilityElements {
accessibilityHierarchyOfElements.append(
Expand Down Expand Up @@ -778,6 +780,51 @@ private extension NSObject {
return recursiveAccessibilityHierarchy
}

/// Returns the explicit accessibility elements exposed by this object. When
/// `accessibilityElements` is set (including to an empty array) it is used directly — this covers
/// UIKit containers such as `UISegmentedControl`, which populate `accessibilityElements` with
/// their internal elements. When it is `nil` and the receiver is not a `UIView`, this falls back
/// to the `accessibilityElementCount()` / `accessibilityElement(at:)` container APIs, mirroring
/// how `UIAccessibilityContainer` consumers (including VoiceOver) resolve elements. This fallback
/// is the case the method exists for: non-`UIView` `NSObject`s have no subviews to traverse, so
/// without it their index-vended elements would be invisible to the parser. `UIView`s are
/// deliberately excluded from the fallback so their existing subview-traversal path (with its
/// grouping, sorting, and pruning) is preserved; in practice no standard UIKit view needs it
/// regardless, since they either populate `accessibilityElements` or report `NSNotFound` from
/// `accessibilityElementCount()`. Returns `nil` when the object does not act as an explicit
/// accessibility container.
private func resolvedAccessibilityElements() -> [NSObject]? {
if let elements = accessibilityElements as? [NSObject] {
return elements
}

guard !(self is UIView) else {
return nil
}

// `accessibilityElementCount()` defaults to `NSNotFound` for objects that don't implement the
// dynamic container methods (its default is undocumented, but its siblings
// `accessibilityElement(at:)` and `index(ofAccessibilityElement:)` document `nil` / `NSNotFound`
// defaults, and `NSNotFound` is what UIKit returns on iOS 17+). Treating that sentinel as a real
// count would iterate ~`NSIntegerMax` times, so guard against it explicitly.
let count = accessibilityElementCount()
guard count > 0, count != NSNotFound else {
return nil
}

var elements: [NSObject] = []
elements.reserveCapacity(count)
for index in 0 ..< count {
// `accessibilityElement(at:)` may return `nil` (or a non-`NSObject`) for individual indices even
// when `accessibilityElementCount()` reports a larger count; those are skipped rather than treated
// as a hard error, mirroring how a consumer iterating the container would tolerate gaps.
if let element = accessibilityElement(at: index) as? NSObject {
elements.append(element)
}
}
return elements.isEmpty ? nil : elements
}

private func containerInfo(for view: UIView) -> ContainerInfo? {
let containerType = view.accessibilityContainerType
let traits = view.accessibilityTraits
Expand Down