Skip to content

Commit 95d1ab8

Browse files
fix: address PR feedback for accessibility deferral
- Fix reduce bug in updateDeferredAccessibility where merge results were discarded (reduce(into:) → reduce() since merge returns a new value) - Remove unused identifiers parameter from deferredAccessibilityReceiver - Fix typo in doc comment ("through he" → "through the") - Simplify customContent type to Accessibility.CustomContent - Remove redundant needsAccessibilityUpdate invalidation - Add AccessibilityDeferralTests covering merge chaining, apply/replace flow, and content generation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b16b63d commit 95d1ab8

2 files changed

Lines changed: 200 additions & 7 deletions

File tree

BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public struct AccessibilityDeferral {
1919
var rotorSequencer: AccessibilityComposition.RotorSequencer? { get set }
2020

2121
/// Custom content that may be supplied in addition to the deferred content
22-
var customContent: [BlueprintUI.Accessibility.CustomContent]? { get set }
22+
var customContent: [Accessibility.CustomContent]? { get set }
2323

2424
/// Content from an outside source that will be exposed via AccessibilityCustomContent
2525
var deferredAccessibilityContent: [AccessibilityDeferral.Content]? { get set }
@@ -53,7 +53,7 @@ public struct AccessibilityDeferral {
5353
/// Used to identify a specific `Source` element to inherit accessibility from.
5454
public var sourceIdentifier: AnyHashable
5555

56-
/// A stable identifier used to identify a given update pass through he view hierarchy. Content with matching updateIdentifiers should be combined.
56+
/// A stable identifier used to identify a given update pass through the view hierarchy. Content with matching updateIdentifiers should be combined.
5757
internal var updateIdentifier: UUID?
5858
internal var inheritedAccessibility: AccessibilityComposition.CompositeRepresentation?
5959

@@ -147,7 +147,7 @@ extension Element {
147147
}
148148

149149
/// Creates a `ReceiverContainer` element to expose the deferred accessibility.
150-
public func deferredAccessibilityReceiver(identifiers: [AnyHashable]) -> AccessibilityDeferral.ReceiverContainer {
150+
public func deferredAccessibilityReceiver() -> AccessibilityDeferral.ReceiverContainer {
151151
AccessibilityDeferral.ReceiverContainer(wrapping: { self })
152152
}
153153
}
@@ -339,16 +339,14 @@ extension AccessibilityDeferral {
339339

340340
self.frameProvider = frameProvider
341341

342-
if var deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }),
342+
if let deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }),
343343
let first = deferred.first
344344
{
345-
346345
mergeValues = deferred.dropFirst()
347-
.reduce(into: first) { result, value in
346+
.reduce(first) { result, value in
348347
result.merge(with: value)
349348
}
350349
}
351-
needsAccessibilityUpdate = true
352350
}
353351
}
354352
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import BlueprintUI
2+
import XCTest
3+
@testable import BlueprintUIAccessibilityCore
4+
5+
6+
class AccessibilityDeferralTests: XCTestCase {
7+
8+
// MARK: - CompositeRepresentation.merge chaining
9+
10+
func test_merge_multiple_representations() {
11+
let rep1 = makeRepresentation(label: "Label 1", value: "Value 1", hint: "Hint 1")
12+
let rep2 = makeRepresentation(label: "Label 2", value: "Value 2", hint: "Hint 2")
13+
let rep3 = makeRepresentation(label: "Label 3", value: "Value 3", hint: "Hint 3")
14+
15+
let representations = [rep1, rep2, rep3]
16+
17+
// This mirrors the exact reduce pattern used in updateDeferredAccessibility
18+
let merged = representations.dropFirst()
19+
.reduce(representations.first!) { result, value in
20+
result.merge(with: value)
21+
}
22+
23+
XCTAssertEqual(merged.label, "Label 1, Label 2, Label 3")
24+
XCTAssertEqual(merged.value, "Value 1, Value 2, Value 3")
25+
XCTAssertEqual(merged.hint, "Hint 1, Hint 2, Hint 3")
26+
}
27+
28+
func test_merge_two_representations() {
29+
let rep1 = makeRepresentation(label: "First", value: "A")
30+
let rep2 = makeRepresentation(label: "Second", value: "B")
31+
32+
let merged = rep1.merge(with: rep2)
33+
34+
XCTAssertEqual(merged.label, "First, Second")
35+
XCTAssertEqual(merged.value, "A, B")
36+
}
37+
38+
func test_merge_preserves_traits() {
39+
let container = NSObject()
40+
let element1 = UIAccessibilityElement(accessibilityContainer: container)
41+
element1.accessibilityLabel = "Header"
42+
element1.accessibilityTraits = .header
43+
44+
let element2 = UIAccessibilityElement(accessibilityContainer: container)
45+
element2.accessibilityLabel = "Static"
46+
element2.accessibilityTraits = .staticText
47+
48+
let rep1 = AccessibilityComposition.CompositeRepresentation([element1]) {}
49+
let rep2 = AccessibilityComposition.CompositeRepresentation([element2]) {}
50+
51+
let merged = rep1.merge(with: rep2)
52+
53+
XCTAssertTrue(merged.traits.contains(.header))
54+
XCTAssertTrue(merged.traits.contains(.staticText))
55+
}
56+
57+
func test_merge_with_nil_returns_self() {
58+
let rep = makeRepresentation(label: "Only", value: "One")
59+
let merged = rep.merge(with: nil)
60+
61+
XCTAssertEqual(merged.label, "Only")
62+
XCTAssertEqual(merged.value, "One")
63+
}
64+
65+
// MARK: - Receiver apply / replace / merge
66+
67+
func test_apply_replaces_content_on_new_updateID() {
68+
let receiver = TestReceiver()
69+
70+
var content1 = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1")
71+
content1.updateIdentifier = UUID()
72+
content1.inheritedAccessibility = makeRepresentation(label: "First")
73+
74+
receiver.apply(content: [content1], frameProvider: nil)
75+
76+
XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1)
77+
XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "First")
78+
}
79+
80+
func test_apply_merges_content_on_same_updateID() {
81+
let receiver = TestReceiver()
82+
let sharedID = UUID()
83+
84+
var content1 = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1")
85+
content1.updateIdentifier = sharedID
86+
content1.inheritedAccessibility = makeRepresentation(label: "First")
87+
88+
var content2 = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source2")
89+
content2.updateIdentifier = sharedID
90+
content2.inheritedAccessibility = makeRepresentation(label: "Second")
91+
92+
// First call replaces
93+
receiver.apply(content: [content1], frameProvider: nil)
94+
XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1)
95+
96+
// Second call with same updateID merges
97+
receiver.apply(content: [content2], frameProvider: nil)
98+
XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 2)
99+
}
100+
101+
func test_apply_clears_content_when_nil() {
102+
let receiver = TestReceiver()
103+
104+
var content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1")
105+
content.updateIdentifier = UUID()
106+
content.inheritedAccessibility = makeRepresentation(label: "Something")
107+
108+
receiver.apply(content: [content], frameProvider: nil)
109+
XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1)
110+
111+
receiver.apply(content: nil, frameProvider: nil)
112+
XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 0)
113+
}
114+
115+
func test_apply_propagates_actions_from_sources() {
116+
let receiver = TestReceiver()
117+
118+
let container = NSObject()
119+
let element = UIAccessibilityElement(accessibilityContainer: container)
120+
element.accessibilityLabel = "Action Source"
121+
element.accessibilityCustomActions = [
122+
UIAccessibilityCustomAction(name: "Test Action") { _ in true },
123+
]
124+
125+
let rep = AccessibilityComposition.CompositeRepresentation([element]) {}
126+
127+
var content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1")
128+
content.updateIdentifier = UUID()
129+
content.inheritedAccessibility = rep
130+
131+
receiver.apply(content: [content], frameProvider: nil)
132+
XCTAssertEqual(receiver.accessibilityCustomActions?.first?.name, "Test Action")
133+
}
134+
135+
// MARK: - Content customContent generation
136+
137+
func test_content_inherited_customContent() {
138+
var content = AccessibilityDeferral.Content(kind: .inherited(.high), identifier: "id")
139+
content.inheritedAccessibility = makeRepresentation(label: "Error Label", value: "Error Value")
140+
141+
let customContent = content.customContent
142+
XCTAssertNotNil(customContent)
143+
XCTAssertEqual(customContent?.label, "Error Label")
144+
XCTAssertEqual(customContent?.value, "Error Value")
145+
XCTAssertEqual(customContent?.importance, .high)
146+
}
147+
148+
func test_content_error_customContent() {
149+
var content = AccessibilityDeferral.Content(kind: .error, identifier: "id")
150+
content.inheritedAccessibility = makeRepresentation(label: "Field", value: "is required")
151+
152+
let customContent = content.customContent
153+
XCTAssertNotNil(customContent)
154+
XCTAssertEqual(customContent?.importance, .high)
155+
}
156+
157+
func test_content_nil_accessibility_returns_nil() {
158+
let content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "id")
159+
XCTAssertNil(content.customContent)
160+
}
161+
162+
// MARK: - Helpers
163+
164+
private func makeRepresentation(
165+
label: String? = nil,
166+
value: String? = nil,
167+
hint: String? = nil
168+
) -> AccessibilityComposition.CompositeRepresentation {
169+
let container = NSObject()
170+
let element = UIAccessibilityElement(accessibilityContainer: container)
171+
element.accessibilityLabel = label
172+
element.accessibilityValue = value
173+
element.accessibilityHint = hint
174+
return AccessibilityComposition.CompositeRepresentation([element]) {}
175+
}
176+
}
177+
178+
// MARK: - Test Doubles
179+
180+
private final class TestReceiver: UIView, AccessibilityDeferral.Receiver, AXCustomContentProvider {
181+
182+
var accessibilityCustomContent: [AXCustomContent]! = []
183+
184+
var customContent: [Accessibility.CustomContent]?
185+
186+
var rotorSequencer: AccessibilityComposition.RotorSequencer?
187+
188+
var deferredAccessibilityContent: [AccessibilityDeferral.Content]?
189+
190+
func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) {
191+
// No-op for unit testing the apply/replace/merge flow
192+
}
193+
}
194+
195+
extension TestReceiver: AccessibilityDeferral.DeferralView {}

0 commit comments

Comments
 (0)