Skip to content

Commit e9cb098

Browse files
Add interactive mutation tests and document private vs public expanded status findings
Adds interactive test UI for investigating _accessibilityExpandedStatus behavior on real devices, and updates doc comments across the parser to reflect findings: - _accessibilityExpandedStatus is the VoiceOver source of truth (since iOS 14.2) - Public iOS 18 accessibilityExpandedStatus syncs TO private, not vice versa - SwiftUI DisclosureGroup only sets the private API Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e59b92 commit e9cb098

6 files changed

Lines changed: 1372 additions & 9 deletions

File tree

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import UIKit
23

34
@available(iOS 16.0, *)
45
struct SwiftUIDisclosureGroup: View {
@@ -7,14 +8,188 @@ struct SwiftUIDisclosureGroup: View {
78

89
var body: some View {
910
List {
10-
DisclosureGroup("Expanded Section", isExpanded: $isExpanded) {
11-
Text("Item 1")
12-
Text("Item 2")
11+
// MARK: - Normal DisclosureGroups (SwiftUI)
12+
13+
Section("SwiftUI DisclosureGroup") {
14+
DisclosureGroup("Expanded Section", isExpanded: $isExpanded) {
15+
Text("Item 1")
16+
Text("Item 2")
17+
}
18+
19+
DisclosureGroup("Collapsed Section", isExpanded: $isCollapsed) {
20+
Text("Hidden Item")
21+
}
1322
}
1423

15-
DisclosureGroup("Collapsed Section", isExpanded: $isCollapsed) {
16-
Text("Hidden Item")
24+
// MARK: - Interactive mutation tests
25+
26+
if #available(iOS 18.0, *) {
27+
Section("Interactive Mutation Tests") {
28+
MutationTestRow().frame(height: 300)
29+
}
1730
}
1831
}
1932
}
2033
}
34+
35+
// MARK: - Interactive mutation test view (no override — reads Apple's real behavior)
36+
37+
@available(iOS 18.0, *)
38+
private struct MutationTestRow: UIViewRepresentable {
39+
func makeUIView(context: Context) -> MutationTestContainerView {
40+
return MutationTestContainerView()
41+
}
42+
43+
func updateUIView(_ uiView: MutationTestContainerView, context: Context) {}
44+
}
45+
46+
@available(iOS 18.0, *)
47+
private class MutationTestContainerView: UIView {
48+
// Plain UIView — NO override of _accessibilityExpandedStatus
49+
private let testView = UIView()
50+
private let logLabel = UILabel()
51+
private var logLines: [String] = []
52+
53+
private let privateGetSel = NSSelectorFromString("_accessibilityExpandedStatus")
54+
private let privateSetSel = NSSelectorFromString("_setAccessibilityExpandedStatus:")
55+
56+
override init(frame: CGRect) {
57+
super.init(frame: frame)
58+
setup()
59+
}
60+
61+
@available(*, unavailable)
62+
required init?(coder: NSCoder) { fatalError() }
63+
64+
private func readPrivate() -> Int {
65+
guard testView.responds(to: privateGetSel) else { return -1 }
66+
let imp = testView.method(for: privateGetSel)
67+
typealias Fn = @convention(c) (AnyObject, Selector) -> Int
68+
let fn = unsafeBitCast(imp, to: Fn.self)
69+
return fn(testView, privateGetSel)
70+
}
71+
72+
private func writePrivate(_ value: Int) -> Bool {
73+
guard testView.responds(to: privateSetSel) else { return false }
74+
let imp = testView.method(for: privateSetSel)
75+
typealias Fn = @convention(c) (AnyObject, Selector, Int) -> Void
76+
let fn = unsafeBitCast(imp, to: Fn.self)
77+
fn(testView, privateSetSel, value)
78+
return true
79+
}
80+
81+
private func readPublic() -> Int {
82+
return testView.accessibilityExpandedStatus.rawValue
83+
}
84+
85+
private func log(_ msg: String) {
86+
logLines.append(msg)
87+
logLabel.text = logLines.joined(separator: "\n")
88+
}
89+
90+
private func logState(_ prefix: String) {
91+
log("\(prefix) → pub=\(readPublic()) priv=\(readPrivate())")
92+
}
93+
94+
private func setup() {
95+
testView.isAccessibilityElement = true
96+
testView.accessibilityLabel = "Test Target"
97+
98+
// Check if private setter exists
99+
let hasPrivateSetter = testView.responds(to: privateSetSel)
100+
101+
logLabel.numberOfLines = 0
102+
logLabel.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
103+
logLabel.translatesAutoresizingMaskIntoConstraints = false
104+
addSubview(logLabel)
105+
NSLayoutConstraint.activate([
106+
logLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8),
107+
logLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
108+
logLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
109+
])
110+
111+
let stack = UIStackView()
112+
stack.axis = .vertical
113+
stack.spacing = 4
114+
stack.translatesAutoresizingMaskIntoConstraints = false
115+
addSubview(stack)
116+
NSLayoutConstraint.activate([
117+
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
118+
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
119+
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
120+
])
121+
122+
func btn(_ title: String, _ action: @escaping () -> Void) -> UIButton {
123+
let b = UIButton(type: .system)
124+
b.setTitle(title, for: .normal)
125+
b.titleLabel?.font = .systemFont(ofSize: 12)
126+
b.addAction(UIAction { _ in action() }, for: .touchUpInside)
127+
return b
128+
}
129+
130+
// Row 1: Public setter
131+
let row1 = UIStackView(arrangedSubviews: [
132+
btn("pub=expanded") { [weak self] in
133+
self?.testView.accessibilityExpandedStatus = .expanded
134+
self?.logState("pub=expanded")
135+
},
136+
btn("pub=collapsed") { [weak self] in
137+
self?.testView.accessibilityExpandedStatus = .collapsed
138+
self?.logState("pub=collapsed")
139+
},
140+
btn("pub=unsupported") { [weak self] in
141+
self?.testView.accessibilityExpandedStatus = .unsupported
142+
self?.logState("pub=unsupported")
143+
},
144+
])
145+
row1.distribution = .fillEqually
146+
147+
// Row 2: Private setter (if it exists)
148+
let row2 = UIStackView(arrangedSubviews: [
149+
btn("_priv=1(exp)") { [weak self] in
150+
guard let self else { return }
151+
if self.writePrivate(1) {
152+
self.logState("_priv=1")
153+
} else {
154+
self.log("_setAccessibilityExpandedStatus: NOT FOUND")
155+
}
156+
},
157+
btn("_priv=2(col)") { [weak self] in
158+
guard let self else { return }
159+
if self.writePrivate(2) {
160+
self.logState("_priv=2")
161+
} else {
162+
self.log("_setAccessibilityExpandedStatus: NOT FOUND")
163+
}
164+
},
165+
btn("_priv=0(unsup)") { [weak self] in
166+
guard let self else { return }
167+
if self.writePrivate(0) {
168+
self.logState("_priv=0")
169+
} else {
170+
self.log("_setAccessibilityExpandedStatus: NOT FOUND")
171+
}
172+
},
173+
])
174+
row2.distribution = .fillEqually
175+
176+
// Row 3: Read / Clear
177+
let row3 = UIStackView(arrangedSubviews: [
178+
btn("Read State") { [weak self] in
179+
self?.logState("Read")
180+
},
181+
btn("Clear Log") { [weak self] in
182+
self?.logLines = []
183+
self?.logLabel.text = ""
184+
},
185+
])
186+
row3.distribution = .fillEqually
187+
188+
stack.addArrangedSubview(row1)
189+
stack.addArrangedSubview(row2)
190+
stack.addArrangedSubview(row3)
191+
192+
log("_setAccessibilityExpandedStatus exists: \(hasPrivateSetter)")
193+
logState("Initial")
194+
}
195+
}

0 commit comments

Comments
 (0)