Skip to content

Commit 0d680aa

Browse files
camilopiresclaude
andcommitted
Test accessibility preferences through notifications and native calls
RCTAccessibilityManager now reads the On/Off Labels, Differentiate Without Color and Switch Control settings through readers that tests can replace (RCTAccessibilityManagerSetSystemReaders, in +Internal.h). The tests post the real UIKit notifications and call getCurrentPreferences and getRecommendedTimeoutMillis, so they cover the observers, the deduplication and the native callbacks, not just a helper. This replaces the two earlier test hooks. RNTester's preferences row also reads the snapshot and the recommended timeout again when the app becomes active, because Switch Control has no change event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e9de122 commit 0d680aa

4 files changed

Lines changed: 145 additions & 54 deletions

File tree

‎packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled(
1919
RCTAccessibilityManager *accessibilityManager,
2020
BOOL isVoiceOverEnabled);
2121

22-
// Applies a preferences snapshot and sends `preferencesChanged` if it differs
23-
// from the last one. Returns whether it sent the event.
24-
BOOL RCTAccessibilityManagerSetPreferences(
25-
RCTAccessibilityManager *accessibilityManager,
26-
NSDictionary<NSString *, NSNumber *> *preferences);
27-
28-
// The timeout returned by getRecommendedTimeoutMillis.
29-
double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning);
22+
typedef NSDictionary<NSString *, NSNumber *> *_Nonnull (^RCTAccessibilityPreferencesReader)(void);
23+
typedef BOOL (^RCTSwitchControlReader)(void);
24+
25+
// Replaces how the module reads the system settings behind getPreferences()
26+
// and getRecommendedTimeoutMillis. Pass nil to read the system again.
27+
void RCTAccessibilityManagerSetSystemReaders(
28+
RCTAccessibilityPreferencesReader _Nullable preferencesReader,
29+
RCTSwitchControlReader _Nullable switchControlReader);
3030

3131
RCT_EXTERN_C_END
3232

‎packages/react-native/React/CoreModules/RCTAccessibilityManager.mm‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,26 @@ static NSNotificationName RCTShowBordersStatusDidChangeNotification()
6060

6161
// The settings behind AccessibilityInfo.getPreferences(), named by what the user
6262
// needs rather than by the iOS setting.
63-
static NSDictionary<NSString *, NSNumber *> *RCTCurrentAccessibilityPreferences()
63+
static NSDictionary<NSString *, NSNumber *> *RCTSystemAccessibilityPreferences(void)
6464
{
6565
return @{
6666
@"prefersSwitchLabels" : @(UIAccessibilityIsOnOffSwitchLabelsEnabled()),
6767
@"prefersNonColorIndicators" : @(UIAccessibilityShouldDifferentiateWithoutColor()),
6868
};
6969
}
7070

71-
double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning)
71+
// Replaced in tests through RCTAccessibilityManagerSetSystemReaders.
72+
static RCTAccessibilityPreferencesReader sPreferencesReader = nil;
73+
static RCTSwitchControlReader sSwitchControlReader = nil;
74+
75+
static NSDictionary<NSString *, NSNumber *> *RCTCurrentAccessibilityPreferences(void)
7276
{
73-
// Moving between elements one switch press at a time is slow, so allow
74-
// three times as long while Switch Control is running.
75-
return isSwitchControlRunning ? originalTimeout * 3 : originalTimeout;
77+
return sPreferencesReader != nil ? sPreferencesReader() : RCTSystemAccessibilityPreferences();
78+
}
79+
80+
static BOOL RCTIsSwitchControlRunning(void)
81+
{
82+
return sSwitchControlReader != nil ? sSwitchControlReader() : UIAccessibilityIsSwitchControlRunning();
7683
}
7784

7885
@implementation RCTAccessibilityManager
@@ -291,17 +298,15 @@ - (void)preferencesStatusDidChange:(__unused NSNotification *)notification
291298
[self _setPreferences:RCTCurrentAccessibilityPreferences()];
292299
}
293300

294-
- (BOOL)_setPreferences:(NSDictionary<NSString *, NSNumber *> *)preferences
301+
- (void)_setPreferences:(NSDictionary<NSString *, NSNumber *> *)preferences
295302
{
296-
if ([_preferences isEqualToDictionary:preferences]) {
297-
return NO;
298-
}
299-
_preferences = [preferences copy];
303+
if (![_preferences isEqualToDictionary:preferences]) {
304+
_preferences = [preferences copy];
300305
#pragma clang diagnostic push
301306
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
302-
[[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences];
307+
[[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences];
303308
#pragma clang diagnostic pop
304-
return YES;
309+
}
305310
}
306311

307312
- (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification
@@ -525,7 +530,9 @@ - (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(__unuse
525530

526531
- (void)getRecommendedTimeoutMillis:(double)mSec onSuccess:(RCTResponseSenderBlock)onSuccess
527532
{
528-
onSuccess(@[ @(RCTRecommendedTimeoutMillis(mSec, UIAccessibilityIsSwitchControlRunning())) ]);
533+
// Moving between elements one switch press at a time is slow, so allow
534+
// three times as long while Switch Control is running.
535+
onSuccess(@[ @(RCTIsSwitchControlRunning() ? mSec * 3 : mSec) ]);
529536
}
530537

531538
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
@@ -543,11 +550,12 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled(
543550
[accessibilityManager _setIsVoiceOverEnabled:isVoiceOverEnabled];
544551
}
545552

546-
BOOL RCTAccessibilityManagerSetPreferences(
547-
RCTAccessibilityManager *accessibilityManager,
548-
NSDictionary<NSString *, NSNumber *> *preferences)
553+
void RCTAccessibilityManagerSetSystemReaders(
554+
RCTAccessibilityPreferencesReader _Nullable preferencesReader,
555+
RCTSwitchControlReader _Nullable switchControlReader)
549556
{
550-
return [accessibilityManager _setPreferences:preferences];
557+
sPreferencesReader = preferencesReader;
558+
sSwitchControlReader = switchControlReader;
551559
}
552560

553561
@end

‎packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m‎

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,26 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
#import <UIKit/UIKit.h>
89
#import <XCTest/XCTest.h>
910

1011
#import <React/RCTAccessibilityManager.h>
1112
#import <React/RCTBridgeModule.h>
1213

13-
extern BOOL RCTAccessibilityManagerSetPreferences(
14-
RCTAccessibilityManager *accessibilityManager,
15-
NSDictionary<NSString *, NSNumber *> *preferences);
16-
extern double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning);
14+
// From RCTAccessibilityManager+Internal.h.
15+
typedef NSDictionary<NSString *, NSNumber *> * (^RCTAccessibilityPreferencesReader)(void);
16+
typedef BOOL (^RCTSwitchControlReader)(void);
17+
extern void RCTAccessibilityManagerSetSystemReaders(
18+
RCTAccessibilityPreferencesReader preferencesReader,
19+
RCTSwitchControlReader switchControlReader);
20+
21+
// The native module methods JS calls, from the generated spec.
22+
@interface RCTAccessibilityManager (RCTAccessibilityManagerTests)
23+
24+
- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(RCTResponseSenderBlock)onError;
25+
- (void)getRecommendedTimeoutMillis:(double)mSec onSuccess:(RCTResponseSenderBlock)onSuccess;
26+
27+
@end
1728

1829
// Records the device events the module sends.
1930
@interface RCTAccessibilityManagerTestsEventDispatcher : NSObject
@@ -65,48 +76,108 @@ @implementation RCTAccessibilityManagerTests {
6576
RCTAccessibilityManagerTestsEventDispatcher *_eventDispatcher;
6677
// The module holds its registry weakly.
6778
RCTAccessibilityManagerTestsModuleRegistry *_moduleRegistry;
79+
NSDictionary<NSString *, NSNumber *> *_systemPreferences;
80+
BOOL _isSwitchControlRunning;
6881
}
6982

7083
- (void)setUp
7184
{
7285
[super setUp];
86+
_systemPreferences = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO};
87+
_isSwitchControlRunning = NO;
88+
__weak RCTAccessibilityManagerTests *weakSelf = self;
89+
RCTAccessibilityManagerSetSystemReaders(
90+
^NSDictionary<NSString *, NSNumber *> * {
91+
RCTAccessibilityManagerTests *strongSelf = weakSelf;
92+
return strongSelf ? strongSelf->_systemPreferences : @{};
93+
},
94+
^BOOL {
95+
RCTAccessibilityManagerTests *strongSelf = weakSelf;
96+
return strongSelf ? strongSelf->_isSwitchControlRunning : NO;
97+
});
98+
7399
_eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new];
74100
_moduleRegistry = [RCTAccessibilityManagerTestsModuleRegistry new];
75101
_moduleRegistry.eventDispatcher = _eventDispatcher;
76102
_manager = [RCTAccessibilityManager new];
77103
_manager.moduleRegistry = _moduleRegistry;
78104
}
79105

80-
- (void)testRecommendedTimeoutIsLongerWhileSwitchControlIsRunning
106+
- (void)tearDown
81107
{
82-
XCTAssertEqual(RCTRecommendedTimeoutMillis(4000, YES), 12000);
83-
XCTAssertEqual(RCTRecommendedTimeoutMillis(4000, NO), 4000);
84-
XCTAssertEqual(RCTRecommendedTimeoutMillis(0, YES), 0);
108+
RCTAccessibilityManagerSetSystemReaders(nil, nil);
109+
_manager = nil;
110+
[super tearDown];
85111
}
86112

87-
- (void)testUnchangedPreferencesSendNoEvent
113+
- (NSDictionary *)currentPreferences
88114
{
89-
NSDictionary *preferences = @{@"prefersSwitchLabels" : @YES, @"prefersNonColorIndicators" : @NO};
115+
__block NSDictionary *preferences = nil;
116+
[_manager
117+
getCurrentPreferences:^(NSArray *response) {
118+
preferences = response.firstObject;
119+
}
120+
onError:^(__unused NSArray *response) {
121+
XCTFail(@"getCurrentPreferences failed");
122+
}];
123+
return preferences;
124+
}
90125

91-
RCTAccessibilityManagerSetPreferences(_manager, preferences);
92-
[_eventDispatcher.events removeAllObjects];
93-
BOOL sent = RCTAccessibilityManagerSetPreferences(_manager, [preferences copy]);
126+
- (NSNumber *)recommendedTimeoutFor:(double)mSec
127+
{
128+
__block NSNumber *timeout = nil;
129+
[_manager getRecommendedTimeoutMillis:mSec
130+
onSuccess:^(NSArray *response) {
131+
timeout = response.firstObject;
132+
}];
133+
return timeout;
134+
}
94135

95-
XCTAssertFalse(sent);
96-
XCTAssertEqual(_eventDispatcher.events.count, 0);
136+
- (void)testGetCurrentPreferencesReturnsTheSettingsAtStartup
137+
{
138+
XCTAssertEqualObjects(
139+
[self currentPreferences], (@{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO}));
140+
}
141+
142+
- (void)testOnOffSwitchLabelsChangeSendsTheWholeSnapshot
143+
{
144+
_systemPreferences = @{@"prefersSwitchLabels" : @YES, @"prefersNonColorIndicators" : @NO};
145+
[[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityOnOffSwitchLabelsDidChangeNotification
146+
object:nil];
147+
148+
XCTAssertEqualObjects(_eventDispatcher.events, (@[ @[ @"preferencesChanged", _systemPreferences ] ]));
149+
XCTAssertEqualObjects([self currentPreferences], _systemPreferences);
150+
}
151+
152+
- (void)testDifferentiateWithoutColorChangeSendsTheWholeSnapshot
153+
{
154+
_systemPreferences = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @YES};
155+
[[NSNotificationCenter defaultCenter]
156+
postNotificationName:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification
157+
object:nil];
158+
159+
XCTAssertEqualObjects(_eventDispatcher.events, (@[ @[ @"preferencesChanged", _systemPreferences ] ]));
160+
XCTAssertEqualObjects([self currentPreferences], _systemPreferences);
97161
}
98162

99-
- (void)testChangedPreferencesSendTheWholeSnapshot
163+
- (void)testNotificationWithoutAChangeSendsNoEvent
100164
{
101-
RCTAccessibilityManagerSetPreferences(_manager, @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO});
102-
[_eventDispatcher.events removeAllObjects];
165+
[[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityOnOffSwitchLabelsDidChangeNotification
166+
object:nil];
167+
[[NSNotificationCenter defaultCenter]
168+
postNotificationName:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification
169+
object:nil];
170+
171+
XCTAssertEqual(_eventDispatcher.events.count, 0);
172+
}
103173

104-
NSDictionary *changed = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @YES};
105-
BOOL sent = RCTAccessibilityManagerSetPreferences(_manager, changed);
174+
- (void)testRecommendedTimeoutIsLongerWhileSwitchControlIsRunning
175+
{
176+
XCTAssertEqualObjects([self recommendedTimeoutFor:4000], @4000);
106177

107-
XCTAssertTrue(sent);
108-
XCTAssertEqual(_eventDispatcher.events.count, 1);
109-
XCTAssertEqualObjects(_eventDispatcher.events.firstObject, (@[ @"preferencesChanged", changed ]));
178+
_isSwitchControlRunning = YES;
179+
XCTAssertEqualObjects([self recommendedTimeoutFor:4000], @12000);
180+
XCTAssertEqualObjects([self recommendedTimeoutFor:0], @0);
110181
}
111182

112183
@end

‎packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {createRef, useEffect, useRef, useState} from 'react';
2727
import {
2828
AccessibilityInfo,
2929
Alert,
30+
AppState,
3031
Button,
3132
Image,
3233
ImageBackground,
@@ -1672,14 +1673,25 @@ function PreferencesStatusExample(): React.Node {
16721673
setLastEvent(newPreferences);
16731674
},
16741675
);
1675-
// $FlowFixMe[unused-promise]
1676-
AccessibilityInfo.getPreferences().then(setPreferences);
1677-
// $FlowFixMe[unused-promise]
1678-
AccessibilityInfo.getRecommendedTimeoutMillis(4000).then(
1679-
setRecommendedTimeout,
1680-
);
1676+
// Switch Control has no change event, so read again when the app comes
1677+
// back from Settings.
1678+
const read = () => {
1679+
// $FlowFixMe[unused-promise]
1680+
AccessibilityInfo.getPreferences().then(setPreferences);
1681+
// $FlowFixMe[unused-promise]
1682+
AccessibilityInfo.getRecommendedTimeoutMillis(4000).then(
1683+
setRecommendedTimeout,
1684+
);
1685+
};
1686+
read();
1687+
const appStateListener = AppState.addEventListener('change', state => {
1688+
if (state === 'active') {
1689+
read();
1690+
}
1691+
});
16811692
return function cleanup() {
16821693
listener.remove();
1694+
appStateListener.remove();
16831695
};
16841696
}, []);
16851697
return (

0 commit comments

Comments
 (0)