blob: d48ec5f37852e8de7077dc95ae0cd45c1df20172 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:typed_data';
import 'package:quiver/testing/async.dart';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
import '../../common/rendering.dart';
import '../../common/test_initialization.dart';
import 'semantics_tester.dart';
DateTime _testTime = DateTime(2018, 12, 17);
EngineSemantics semantics() => EngineSemantics.instance;
EngineSemanticsOwner owner() => EnginePlatformDispatcher.instance.implicitView!.semantics;
DomElement get platformViewsHost =>
EnginePlatformDispatcher.instance.implicitView!.dom.platformViewsHost;
void main() {
internalBootstrapBrowserTest(() {
return testMain;
});
}
Future<void> testMain() async {
await bootstrapAndRunApp(withImplicitView: true);
setUpRenderingForTests();
runSemanticsTests();
}
void runSemanticsTests() {
setUp(() {
EngineSemantics.debugResetSemantics();
});
group(EngineSemanticsOwner, () {
_testEngineSemanticsOwner();
});
group('longestIncreasingSubsequence', () {
_testLongestIncreasingSubsequence();
});
group('Role managers', () {
_testRoleManagerLifecycle();
});
group('Text', () {
_testText();
});
group('labels', () {
_testLabels();
});
group('container', () {
_testContainer();
});
group('vertical scrolling', () {
_testVerticalScrolling();
});
group('horizontal scrolling', () {
_testHorizontalScrolling();
});
group('incrementable', () {
_testIncrementables();
});
group('text field', () {
_testTextField();
});
group('checkboxes, radio buttons and switches', () {
_testCheckables();
});
group('tappable', () {
_testTappable();
});
group('image', () {
_testImage();
});
group('header', () {
_testHeader();
});
group('live region', () {
_testLiveRegion();
});
group('platform view', () {
_testPlatformView();
});
group('accessibility builder', () {
_testEngineAccessibilityBuilder();
});
group('group', () {
_testGroup();
});
group('dialog', () {
_testDialog();
});
group('focusable', () {
_testFocusable();
});
group('link', () {
_testLink();
});
}
void _testRoleManagerLifecycle() {
test('Secondary role managers are added upon node initialization', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
// Check that roles are initialized immediately
{
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
tester.expectSemantics('<sem role="button" style="$rootSemanticStyle"></sem>');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.button);
expect(
node.primaryRole?.debugSecondaryRoles,
containsAll(<Role>[Role.focusable, Role.tappable, Role.labelAndValue]),
);
expect(tester.getSemanticsObject(0).element.tabIndex, -1);
}
// Check that roles apply their functionality upon update.
{
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
label: 'a label',
isFocusable: true,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
tester.expectSemantics('<sem role="button" style="$rootSemanticStyle">a label</sem>');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.button);
expect(
node.primaryRole?.debugSecondaryRoles,
containsAll(<Role>[Role.focusable, Role.tappable, Role.labelAndValue]),
);
expect(tester.getSemanticsObject(0).element.tabIndex, 0);
}
semantics().semanticsEnabled = false;
});
}
void _testEngineAccessibilityBuilder() {
final EngineAccessibilityFeaturesBuilder builder =
EngineAccessibilityFeaturesBuilder(0);
EngineAccessibilityFeatures features = builder.build();
test('accessible navigation', () {
expect(features.accessibleNavigation, isFalse);
builder.accessibleNavigation = true;
features = builder.build();
expect(features.accessibleNavigation, isTrue);
});
test('bold text', () {
expect(features.boldText, isFalse);
builder.boldText = true;
features = builder.build();
expect(features.boldText, isTrue);
});
test('disable animations', () {
expect(features.disableAnimations, isFalse);
builder.disableAnimations = true;
features = builder.build();
expect(features.disableAnimations, isTrue);
});
test('high contrast', () {
expect(features.highContrast, isFalse);
builder.highContrast = true;
features = builder.build();
expect(features.highContrast, isTrue);
});
test('invert colors', () {
expect(features.invertColors, isFalse);
builder.invertColors = true;
features = builder.build();
expect(features.invertColors, isTrue);
});
test('on off switch labels', () {
expect(features.onOffSwitchLabels, isFalse);
builder.onOffSwitchLabels = true;
features = builder.build();
expect(features.onOffSwitchLabels, isTrue);
});
test('reduce motion', () {
expect(features.reduceMotion, isFalse);
builder.reduceMotion = true;
features = builder.build();
expect(features.reduceMotion, isTrue);
});
}
void _testEngineSemanticsOwner() {
test('instantiates a singleton', () {
expect(semantics(), same(semantics()));
});
test('semantics is off by default', () {
expect(semantics().semanticsEnabled, isFalse);
});
test('default mode is "unknown"', () {
expect(semantics().mode, AccessibilityMode.unknown);
});
// Expecting the following DOM structure by default:
//
// <body>
// <flt-announcement-host>
// <flt-announcement-polite></flt-announcement-polite>
// <flt-announcement-assertive></flt-announcement-assertive>
// </flt-announcement-host>
// </body>
test('places accessibility announcements in the <body> tag', () {
final AccessibilityAnnouncements accessibilityAnnouncements = semantics().accessibilityAnnouncements;
final DomElement politeElement = accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite);
final DomElement assertiveElement = accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive);
final DomElement announcementHost = politeElement.parent!;
// Polite and assertive elements share the same host.
expect(
assertiveElement.parent,
announcementHost,
);
// The host is a direct child of <body>
expect(announcementHost.parent, domDocument.body);
});
test('accessibilityFeatures copyWith function works', () {
const EngineAccessibilityFeatures original = EngineAccessibilityFeatures(0);
EngineAccessibilityFeatures copy =
original.copyWith(accessibleNavigation: true);
expect(copy.accessibleNavigation, true);
expect(copy.boldText, false);
expect(copy.disableAnimations, false);
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(boldText: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, true);
expect(copy.disableAnimations, false);
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(disableAnimations: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
expect(copy.disableAnimations, true);
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(highContrast: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
expect(copy.disableAnimations, false);
expect(copy.highContrast, true);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(invertColors: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
expect(copy.disableAnimations, false);
expect(copy.highContrast, false);
expect(copy.invertColors, true);
expect(copy.onOffSwitchLabels, false);
expect(copy.reduceMotion, false);
copy = original.copyWith(onOffSwitchLabels: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
expect(copy.disableAnimations, false);
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, true);
expect(copy.reduceMotion, false);
copy = original.copyWith(reduceMotion: true);
expect(copy.accessibleNavigation, false);
expect(copy.boldText, false);
expect(copy.disableAnimations, false);
expect(copy.highContrast, false);
expect(copy.invertColors, false);
expect(copy.onOffSwitchLabels, false);
expect(copy.reduceMotion, true);
});
void renderSemantics({String? label, String? tooltip, Set<ui.SemanticsFlag> flags = const <ui.SemanticsFlag>{}}) {
int flagValues = 0;
for (final ui.SemanticsFlag flag in flags) {
flagValues = flagValues | flag.index;
}
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 20, 20),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
label: label ?? '',
tooltip: tooltip ?? '',
flags: flagValues,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 20, 20),
);
owner().updateSemantics(builder.build());
}
void renderLabel(String label) {
renderSemantics(label: label);
}
test('produces a label', () async {
semantics().semanticsEnabled = true;
// Create
renderLabel('Hello');
final Map<int, SemanticsObject> tree = owner().debugSemanticsTree!;
expect(tree.length, 2);
expect(tree[0]!.id, 0);
expect(tree[0]!.element.tagName.toLowerCase(), 'flt-semantics');
expect(tree[1]!.id, 1);
expect(tree[1]!.label, 'Hello');
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text">Hello</sem>
</sem-c>
</sem>''');
// Update
renderLabel('World');
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text">World</sem>
</sem-c>
</sem>''');
// Remove
renderLabel('');
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text"></sem>
</sem-c>
</sem>''');
semantics().semanticsEnabled = false;
});
test('can switch role', () async {
semantics().semanticsEnabled = true;
// Create
renderSemantics(label: 'Hello');
Map<int, SemanticsObject> tree = owner().debugSemanticsTree!;
expect(tree.length, 2);
expect(tree[1]!.element.tagName.toLowerCase(), 'flt-semantics');
expect(tree[1]!.id, 1);
expect(tree[1]!.label, 'Hello');
final DomElement existingParent = tree[1]!.element.parent!;
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text">Hello</sem>
</sem-c>
</sem>''');
// Update
renderSemantics(label: 'Hello', flags: <ui.SemanticsFlag>{ ui.SemanticsFlag.isLink });
tree = owner().debugSemanticsTree!;
expect(tree.length, 2);
expect(tree[1]!.id, 1);
expect(tree[1]!.label, 'Hello');
expect(tree[1]!.element.tagName.toLowerCase(), 'a');
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<a style="display: block;">Hello</a>
</sem-c>
</sem>''');
expect(existingParent, tree[1]!.element.parent);
semantics().semanticsEnabled = false;
});
test('tooltip is part of label', () async {
semantics().semanticsEnabled = true;
// Create
renderSemantics(tooltip: 'tooltip');
final Map<int, SemanticsObject> tree = owner().debugSemanticsTree!;
expect(tree.length, 2);
expect(tree[0]!.id, 0);
expect(tree[0]!.element.tagName.toLowerCase(), 'flt-semantics');
expect(tree[1]!.id, 1);
expect(tree[1]!.tooltip, 'tooltip');
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem>tooltip</sem>
</sem-c>
</sem>''');
// Update
renderSemantics(label: 'Hello', tooltip: 'tooltip');
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text">tooltip\nHello</sem>
</sem-c>
</sem>''');
// Remove
renderSemantics();
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text"></sem>
</sem-c>
</sem>''');
semantics().semanticsEnabled = false;
});
test('clears semantics tree when disabled', () {
expect(owner().debugSemanticsTree, isEmpty);
semantics().semanticsEnabled = true;
renderLabel('Hello');
expect(owner().debugSemanticsTree, isNotEmpty);
semantics().semanticsEnabled = false;
expect(owner().debugSemanticsTree, isEmpty);
});
test('accepts standalone browser gestures', () {
semantics().semanticsEnabled = true;
expect(semantics().shouldAcceptBrowserGesture('click'), isTrue);
semantics().semanticsEnabled = false;
});
test('rejects browser gestures accompanied by pointer click', () {
FakeAsync().run((FakeAsync fakeAsync) {
semantics()
..debugOverrideTimestampFunction(fakeAsync.getClock(_testTime).now)
..semanticsEnabled = true;
expect(semantics().shouldAcceptBrowserGesture('click'), isTrue);
semantics().receiveGlobalEvent(createDomEvent('Event', 'pointermove'));
expect(semantics().shouldAcceptBrowserGesture('click'), isFalse);
// After 1 second of inactivity a browser gestures counts as standalone.
fakeAsync.elapse(const Duration(seconds: 1));
expect(semantics().shouldAcceptBrowserGesture('click'), isTrue);
semantics().semanticsEnabled = false;
});
});
test('checks shouldEnableSemantics for every global event', () {
final MockSemanticsEnabler mockSemanticsEnabler = MockSemanticsEnabler();
semantics().semanticsHelper.semanticsEnabler = mockSemanticsEnabler;
final DomEvent pointerEvent = createDomEvent('Event', 'pointermove');
semantics().receiveGlobalEvent(pointerEvent);
// Verify the interactions.
expect(
mockSemanticsEnabler.shouldEnableSemanticsEvents,
<DomEvent>[pointerEvent],
);
});
test('forwards events to framework if shouldEnableSemantics returns true',
() {
final MockSemanticsEnabler mockSemanticsEnabler = MockSemanticsEnabler();
semantics().semanticsHelper.semanticsEnabler = mockSemanticsEnabler;
final DomEvent pointerEvent = createDomEvent('Event', 'pointermove');
mockSemanticsEnabler.shouldEnableSemanticsReturnValue = true;
expect(semantics().receiveGlobalEvent(pointerEvent), isTrue);
});
test('semantics owner update phases', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
expect(
reason: 'Should start in idle phase',
owner().phase,
SemanticsUpdatePhase.idle,
);
void pumpSemantics({ required String label }) {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
children: <SemanticsNodeUpdate>[
tester.updateNode(id: 1, label: label),
],
);
tester.apply();
}
SemanticsUpdatePhase? capturedPostUpdateCallbackPhase;
owner().addOneTimePostUpdateCallback(() {
capturedPostUpdateCallbackPhase = owner().phase;
});
pumpSemantics(label: 'Hello');
final SemanticsObject semanticsObject = owner().debugSemanticsTree![1]!;
expect(
reason: 'Should be in postUpdate phase while calling post-update callbacks',
capturedPostUpdateCallbackPhase,
SemanticsUpdatePhase.postUpdate,
);
expect(
reason: 'After the update is done, should go back to idle',
owner().phase,
SemanticsUpdatePhase.idle,
);
// Rudely replace the role manager with a mock, and trigger an update.
final MockRoleManager mockRoleManager = MockRoleManager(PrimaryRole.generic, semanticsObject);
semanticsObject.primaryRole = mockRoleManager;
pumpSemantics(label: 'World');
expect(
reason: 'While updating must be in SemanticsUpdatePhase.updating phase',
mockRoleManager.log,
<MockRoleManagerLogEntry>[
(method: 'update', phase: SemanticsUpdatePhase.updating),
],
);
semantics().semanticsEnabled = false;
});
}
typedef MockRoleManagerLogEntry = ({
String method,
SemanticsUpdatePhase phase,
});
class MockRoleManager extends PrimaryRoleManager {
MockRoleManager(super.role, super.semanticsObject) : super.blank();
final List<MockRoleManagerLogEntry> log = <MockRoleManagerLogEntry>[];
void _log(String method) {
log.add((
method: method,
phase: semanticsObject.owner.phase,
));
}
@override
void update() {
super.update();
_log('update');
}
@override
bool focusAsRouteDefault() {
throw UnimplementedError();
}
}
class MockSemanticsEnabler implements SemanticsEnabler {
@override
void dispose() {}
@override
bool get isWaitingToEnableSemantics => throw UnimplementedError();
@override
DomElement prepareAccessibilityPlaceholder() {
throw UnimplementedError();
}
bool shouldEnableSemanticsReturnValue = false;
final List<DomEvent> shouldEnableSemanticsEvents = <DomEvent>[];
@override
bool shouldEnableSemantics(DomEvent event) {
shouldEnableSemanticsEvents.add(event);
return shouldEnableSemanticsReturnValue;
}
@override
bool tryEnableSemantics(DomEvent event) {
throw UnimplementedError();
}
}
void _testHeader() {
test('renders heading role for headers', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isHeader.index,
label: 'Header of the page',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="heading" style="$rootSemanticStyle">Header of the page</sem>
''');
semantics().semanticsEnabled = false;
});
// When a header has child elements, role="heading" prevents AT from reaching
// child elements. To fix that role="group" is used, even though that causes
// the heading to not be announced as a heading. If the app really needs the
// heading to be announced as a heading, the developer can restructure the UI
// such that the heading is not a parent node, but a side-note, e.g. preceding
// the child list.
test('uses group role for headers when children are present', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isHeader.index,
label: 'Header of the page',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="group" aria-label="Header of the page" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
''');
semantics().semanticsEnabled = false;
});
}
void _testLongestIncreasingSubsequence() {
void expectLis(List<int> list, List<int> seq) {
expect(longestIncreasingSubsequence(list), seq);
}
test('trivial case', () {
expectLis(<int>[], <int>[]);
});
test('longest in the middle', () {
expectLis(<int>[10, 1, 2, 3, 0], <int>[1, 2, 3]);
});
test('longest at head', () {
expectLis(<int>[1, 2, 3, 0], <int>[0, 1, 2]);
});
test('longest at tail', () {
expectLis(<int>[10, 1, 2, 3], <int>[1, 2, 3]);
});
test('longest in a jagged pattern', () {
expectLis(
<int>[0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5], <int>[0, 1, 3, 5, 7, 9]);
});
test('fully sorted up', () {
for (int count = 0; count < 100; count += 1) {
expectLis(
List<int>.generate(count, (int i) => 10 * i),
List<int>.generate(count, (int i) => i),
);
}
});
test('fully sorted down', () {
for (int count = 1; count < 100; count += 1) {
expectLis(
List<int>.generate(count, (int i) => 10 * (count - i)),
<int>[count - 1],
);
}
});
}
void _testText() {
test('renders a piece of plain text', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'plain text',
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(
owner(),
'''<sem role="text" style="$rootSemanticStyle">plain text</sem>''',
);
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.generic);
expect(
node.primaryRole!.secondaryRoleManagers!.map((RoleManager m) => m.runtimeType).toList(),
<Type>[
Focusable,
LiveRegion,
RouteName,
LabelAndValue,
],
);
semantics().semanticsEnabled = false;
});
test('renders a tappable piece of text', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
hasTap: true,
label: 'tappable text',
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(
owner(),
'''<sem flt-tappable="" role="text" style="$rootSemanticStyle">tappable text</sem>''',
);
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.generic);
expect(
node.primaryRole!.secondaryRoleManagers!.map((RoleManager m) => m.runtimeType).toList(),
<Type>[
Focusable,
LiveRegion,
RouteName,
LabelAndValue,
Tappable,
],
);
semantics().semanticsEnabled = false;
});
}
void _testLabels() {
test('computeDomSemanticsLabel combines tooltip, label, value, and hint', () {
expect(
computeDomSemanticsLabel(tooltip: 'tooltip'),
'tooltip',
);
expect(
computeDomSemanticsLabel(label: 'label'),
'label',
);
expect(
computeDomSemanticsLabel(value: 'value'),
'value',
);
expect(
computeDomSemanticsLabel(hint: 'hint'),
'hint',
);
expect(
computeDomSemanticsLabel(tooltip: 'tooltip', label: 'label', hint: 'hint', value: 'value'),
'''
tooltip
label hint value'''
);
expect(
computeDomSemanticsLabel(tooltip: 'tooltip', hint: 'hint', value: 'value'),
'''
tooltip
hint value'''
);
expect(
computeDomSemanticsLabel(tooltip: 'tooltip', label: 'label', value: 'value'),
'''
tooltip
label value'''
);
expect(
computeDomSemanticsLabel(tooltip: 'tooltip', label: 'label', hint: 'hint'),
'''
tooltip
label hint'''
);
});
test('computeDomSemanticsLabel collapses empty labels to null', () {
expect(
computeDomSemanticsLabel(),
isNull,
);
expect(
computeDomSemanticsLabel(tooltip: ''),
isNull,
);
expect(
computeDomSemanticsLabel(label: ''),
isNull,
);
expect(
computeDomSemanticsLabel(value: ''),
isNull,
);
expect(
computeDomSemanticsLabel(hint: ''),
isNull,
);
expect(
computeDomSemanticsLabel(tooltip: '', label: '', hint: '', value: ''),
isNull,
);
expect(
computeDomSemanticsLabel(tooltip: '', hint: '', value: ''),
isNull,
);
expect(
computeDomSemanticsLabel(tooltip: '', label: '', value: ''),
isNull,
);
expect(
computeDomSemanticsLabel(tooltip: '', label: '', hint: ''),
isNull,
);
});
}
void _testContainer() {
test('container node has no transform when there is no rect offset',
() async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
const ui.Rect zeroOffsetRect = ui.Rect.fromLTRB(0, 0, 20, 20);
updateNode(
builder,
transform: Matrix4.identity().toFloat64(),
rect: zeroOffsetRect,
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: zeroOffsetRect,
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
final DomElement parentElement =
owner().semanticsHost.querySelector('flt-semantics')!;
final DomElement container =
owner().semanticsHost.querySelector('flt-semantics-container')!;
if (isMacOrIOS) {
expect(parentElement.style.top, '0px');
expect(parentElement.style.left, '0px');
expect(container.style.top, '0px');
expect(container.style.left, '0px');
} else {
expect(parentElement.style.top, '');
expect(parentElement.style.left, '');
expect(container.style.top, '');
expect(container.style.left, '');
}
expect(parentElement.style.transform, '');
expect(parentElement.style.transformOrigin, '');
expect(container.style.transform, '');
expect(container.style.transformOrigin, '');
semantics().semanticsEnabled = false;
});
test('container node compensates for rect offset', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
final DomElement parentElement =
owner().semanticsHost.querySelector('flt-semantics')!;
final DomElement container =
owner().semanticsHost.querySelector('flt-semantics-container')!;
expect(parentElement.style.transform, 'matrix(1, 0, 0, 1, 10, 10)');
if (isSafari) {
// macOS 13 returns different values than macOS 12.
expect(parentElement.style.transformOrigin, anyOf(contains('0px 0px 0px'), contains('0px 0px')));
} else {
expect(parentElement.style.transformOrigin, '0px 0px 0px');
}
expect(container.style.top, '-10px');
expect(container.style.left, '-10px');
semantics().semanticsEnabled = false;
});
test('0 offsets are not removed for voiceover', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 20, 20),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
final DomElement parentElement =
owner().semanticsHost.querySelector('flt-semantics')!;
final DomElement container =
owner().semanticsHost.querySelector('flt-semantics-container')!;
if (isMacOrIOS) {
expect(parentElement.style.top, '0px');
expect(parentElement.style.left, '0px');
expect(container.style.top, '0px');
expect(container.style.left, '0px');
} else {
expect(parentElement.style.top, '');
expect(parentElement.style.left, '');
expect(container.style.top, '');
expect(container.style.left, '');
}
expect(parentElement.style.transform, '');
expect(parentElement.style.transformOrigin, '');
expect(container.style.transform, '');
expect(container.style.transformOrigin, '');
semantics().semanticsEnabled = false;
});
test('renders in traversal order, hit-tests in reverse z-index order',
() async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
// State 1: render initial tree with middle elements swapped hit-test wise
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 3, 2, 4]),
);
for (int id = 1; id <= 4; id++) {
updateNode(builder, id: id);
}
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 4"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 3"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
}
// State 2: update z-index
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 4"></sem>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
}
// State 3: update traversal order
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[4, 2, 3, 1]),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 1"></sem>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 4"></sem>
</sem-c>
</sem>''');
}
// State 3: update both orders
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[1, 3, 2, 4]),
childrenInHitTestOrder: Int32List.fromList(<int>[3, 4, 1, 2]),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 2"></sem>
<sem style="z-index: 4"></sem>
<sem style="z-index: 1"></sem>
<sem style="z-index: 3"></sem>
</sem-c>
</sem>''');
}
semantics().semanticsEnabled = false;
});
test(
'container nodes are transparent and leaf children are opaque hit-test wise',
() async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2]),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2]),
);
updateNode(builder, id: 1);
updateNode(builder, id: 2);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
final DomElement root = owner().semanticsHost.querySelector('#flt-semantic-node-0')!;
expect(root.style.pointerEvents, 'none');
final DomElement child1 =
owner().semanticsHost.querySelector('#flt-semantic-node-1')!;
expect(child1.style.pointerEvents, 'all');
final DomElement child2 =
owner().semanticsHost.querySelector('#flt-semantic-node-2')!;
expect(child2.style.pointerEvents, 'all');
semantics().semanticsEnabled = false;
});
test('descendant nodes are removed from the node map, unless reparented', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2]),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2]),
);
updateNode(
builder,
id: 1,
childrenInTraversalOrder: Int32List.fromList(<int>[3, 4]),
childrenInHitTestOrder: Int32List.fromList(<int>[3, 4]),
);
updateNode(
builder,
id: 2,
childrenInTraversalOrder: Int32List.fromList(<int>[5, 6]),
childrenInHitTestOrder: Int32List.fromList(<int>[5, 6]),
);
updateNode(builder, id: 3);
updateNode(builder, id: 4);
updateNode(builder, id: 5);
updateNode(builder, id: 6);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 2">
<sem-c>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>
<sem style="z-index: 1">
<sem-c>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>
</sem-c>
</sem>''');
expect(owner().debugSemanticsTree!.keys.toList(), unorderedEquals(<int>[0, 1, 2, 3, 4, 5, 6]));
}
// Remove node #2 => expect nodes #2 and #5 to be removed and #6 reparented.
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
childrenInTraversalOrder: Int32List.fromList(<int>[3, 4, 6]),
childrenInHitTestOrder: Int32List.fromList(<int>[3, 4, 6]),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 2">
<sem-c>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>
</sem-c>
</sem>''');
expect(owner().debugSemanticsTree!.keys.toList(), unorderedEquals(<int>[0, 1, 3, 4, 6]));
}
semantics().semanticsEnabled = false;
});
}
void _testVerticalScrolling() {
test('renders an empty scrollable node', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.scrollUp.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
</sem>''');
final DomElement scrollable = findScrollable(owner());
expect(scrollable.scrollTop, isPositive);
semantics().semanticsEnabled = false;
});
test('scrollable node with children has a container node', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.scrollUp.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
final DomElement scrollable = findScrollable(owner());
expect(scrollable, isNotNull);
// When there's less content than the available size the neutral scrollTop
// is still a positive number.
expect(scrollable.scrollTop, isPositive);
semantics().semanticsEnabled = false;
});
test('scrollable node dispatches scroll events', () async {
Future<ui.SemanticsActionEvent> captureSemanticsEvent() {
final Completer<ui.SemanticsActionEvent> completer = Completer<ui.SemanticsActionEvent>();
ui.PlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
completer.complete(event);
};
return completer.future;
}
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
addTearDown(() async {
semantics().semanticsEnabled = false;
});
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 |
ui.SemanticsAction.scrollUp.index |
ui.SemanticsAction.scrollDown.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3]),
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3]),
);
for (int id = 1; id <= 3; id++) {
updateNode(
builder,
id: id,
transform: Matrix4.translationValues(0, 50.0 * id, 0).toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 50),
);
}
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
<sem-c>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
final DomElement scrollable = owner().debugSemanticsTree![0]!.element;
expect(scrollable, isNotNull);
// When there's more content than the available size the neutral scrollTop
// is greater than 0 with a maximum of 10 or 9.
int browserMaxScrollDiff = 0;
// The max scroll value varies between `9` and `10` for Safari desktop
// browsers.
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.macOs) {
browserMaxScrollDiff = 1;
}
expect(scrollable.scrollTop >= (10 - browserMaxScrollDiff), isTrue);
Future<ui.SemanticsActionEvent> capturedEventFuture = captureSemanticsEvent();
scrollable.scrollTop = 20;
expect(scrollable.scrollTop, 20);
ui.SemanticsActionEvent capturedEvent = await capturedEventFuture;
expect(capturedEvent.nodeId, 0);
expect(capturedEvent.type, ui.SemanticsAction.scrollUp);
expect(capturedEvent.arguments, isNull);
// Engine semantics returns scroll top back to neutral.
expect(scrollable.scrollTop >= (10 - browserMaxScrollDiff), isTrue);
capturedEventFuture = captureSemanticsEvent();
scrollable.scrollTop = 5;
capturedEvent = await capturedEventFuture;
expect(scrollable.scrollTop >= (5 - browserMaxScrollDiff), isTrue);
expect(capturedEvent.nodeId, 0);
expect(capturedEvent.type, ui.SemanticsAction.scrollDown);
expect(capturedEvent.arguments, isNull);
// Engine semantics returns scroll top back to neutral.
expect(scrollable.scrollTop >= (10 - browserMaxScrollDiff), isTrue);
});
}
void _testHorizontalScrolling() {
test('renders an empty scrollable node', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.scrollLeft.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
</sem>''');
semantics().semanticsEnabled = false;
});
test('scrollable node with children has a container node', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.scrollLeft.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
final DomElement scrollable = findScrollable(owner());
expect(scrollable, isNotNull);
// When there's less content than the available size the neutral
// scrollLeft is still a positive number.
expect(scrollable.scrollLeft, isPositive);
semantics().semanticsEnabled = false;
});
test('scrollable node dispatches scroll events', () async {
Future<ui.SemanticsActionEvent> captureSemanticsEvent() {
final Completer<ui.SemanticsActionEvent> completer = Completer<ui.SemanticsActionEvent>();
ui.PlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
completer.complete(event);
};
return completer.future;
}
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
addTearDown(() async {
semantics().semanticsEnabled = false;
});
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 |
ui.SemanticsAction.scrollLeft.index |
ui.SemanticsAction.scrollRight.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3]),
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3]),
);
for (int id = 1; id <= 3; id++) {
updateNode(
builder,
id: id,
transform: Matrix4.translationValues(50.0 * id, 0, 0).toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 50),
);
}
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
<sem-c>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
final DomElement scrollable = findScrollable(owner());
expect(scrollable, isNotNull);
// When there's more content than the available size the neutral scrollTop
// is greater than 0 with a maximum of 10.
int browserMaxScrollDiff = 0;
// The max scroll value varies between `9` and `10` for Safari desktop
// browsers.
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.macOs) {
browserMaxScrollDiff = 1;
}
expect(scrollable.scrollLeft >= (10 - browserMaxScrollDiff), isTrue);
Future<ui.SemanticsActionEvent> capturedEventFuture = captureSemanticsEvent();
scrollable.scrollLeft = 20;
expect(scrollable.scrollLeft, 20);
ui.SemanticsActionEvent capturedEvent = await capturedEventFuture;
expect(capturedEvent.nodeId, 0);
expect(capturedEvent.type, ui.SemanticsAction.scrollLeft);
expect(capturedEvent.arguments, isNull);
// Engine semantics returns scroll position back to neutral.
expect(scrollable.scrollLeft >= (10 - browserMaxScrollDiff), isTrue);
capturedEventFuture = captureSemanticsEvent();
scrollable.scrollLeft = 5;
capturedEvent = await capturedEventFuture;
expect(scrollable.scrollLeft >= (5 - browserMaxScrollDiff), isTrue);
expect(capturedEvent.nodeId, 0);
expect(capturedEvent.type, ui.SemanticsAction.scrollRight);
expect(capturedEvent.arguments, isNull);
// Engine semantics returns scroll top back to neutral.
expect(scrollable.scrollLeft >= (10 - browserMaxScrollDiff), isTrue);
});
}
void _testIncrementables() {
test('renders a trivial incrementable node', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.increase.index,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="1">
</sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.incrementable);
expect(
reason: 'Incrementables use custom focus management',
node.primaryRole!.debugSecondaryRoles,
isNot(contains(Role.focusable)),
);
semantics().semanticsEnabled = false;
});
test('increments', () async {
final SemanticsActionLogger logger = SemanticsActionLogger();
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.increase.index,
value: 'd',
increasedValue: 'e',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="1">
</sem>''');
final DomHTMLInputElement input =
owner().semanticsHost.querySelector('input')! as DomHTMLInputElement;
input.value = '2';
input.dispatchEvent(createDomEvent('Event', 'change'));
expect(await logger.idLog.first, 0);
expect(await logger.actionLog.first, ui.SemanticsAction.increase);
semantics().semanticsEnabled = false;
});
test('decrements', () async {
final SemanticsActionLogger logger = SemanticsActionLogger();
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.decrease.index,
value: 'd',
decreasedValue: 'c',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="0">
</sem>''');
final DomHTMLInputElement input =
owner().semanticsHost.querySelector('input')! as DomHTMLInputElement;
input.value = '0';
input.dispatchEvent(createDomEvent('Event', 'change'));
expect(await logger.idLog.first, 0);
expect(await logger.actionLog.first, ui.SemanticsAction.decrease);
semantics().semanticsEnabled = false;
});
test('renders a node that can both increment and decrement', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 |
ui.SemanticsAction.decrease.index |
ui.SemanticsAction.increase.index,
value: 'd',
increasedValue: 'e',
decreasedValue: 'c',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="0">
</sem>''');
semantics().semanticsEnabled = false;
});
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required bool isFocused }) {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
hasIncrease: true,
isFocusable: true,
isFocused: isFocused,
hasEnabledState: true,
isEnabled: true,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
pumpSemantics(isFocused: false);
final DomElement element = owner().debugSemanticsTree![0]!.element.querySelector('input')!;
expect(capturedActions, isEmpty);
pumpSemantics(isFocused: true);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
capturedActions.clear();
pumpSemantics(isFocused: false);
expect(
reason: 'The engine never calls blur() explicitly.',
capturedActions,
isEmpty,
);
element.blur();
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
semantics().semanticsEnabled = false;
});
}
void _testTextField() {
test('renders a text field', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 | ui.SemanticsFlag.isTextField.index,
value: 'hello',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<input value="hello" />
</sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.textField);
expect(
reason: 'Text fields use custom focus management',
node.primaryRole!.debugSecondaryRoles,
isNot(contains(Role.focusable)),
);
semantics().semanticsEnabled = false;
});
// TODO(yjbanov): this test will need to be adjusted for Safari when we add
// Safari testing.
test('sends a focus action when text field is activated', () async {
final SemanticsActionLogger logger = SemanticsActionLogger();
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.didGainAccessibilityFocus.index,
flags: 0 | ui.SemanticsFlag.isTextField.index,
value: 'hello',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
final DomElement textField =
owner().semanticsHost.querySelector('input[data-semantics-role="text-field"]')!;
expect(owner().semanticsHost.ownerDocument?.activeElement, isNot(textField));
textField.focus();
expect(owner().semanticsHost.ownerDocument?.activeElement, textField);
expect(await logger.idLog.first, 0);
expect(await logger.actionLog.first, ui.SemanticsAction.didGainAccessibilityFocus);
semantics().semanticsEnabled = false;
}, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50590
skip: browserEngine != BrowserEngine.blink);
}
void _testCheckables() {
test('renders a switched on switch element', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
label: 'test label',
flags: 0 |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.hasToggledState.index |
ui.SemanticsFlag.isToggled.index |
ui.SemanticsFlag.isFocusable.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem aria-label="test label" flt-tappable role="switch" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.checkable);
expect(
reason: 'Checkables use generic secondary roles',
node.primaryRole!.debugSecondaryRoles,
containsAll(<Role>[Role.focusable, Role.tappable]),
);
semantics().semanticsEnabled = false;
});
test('renders a switched on disabled switch element', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasToggledState.index |
ui.SemanticsFlag.isToggled.index |
ui.SemanticsFlag.hasEnabledState.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="switch" aria-disabled="true" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders a switched off switch element', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasToggledState.index |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="switch" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders a checked checkbox', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.hasCheckedState.index |
ui.SemanticsFlag.isChecked.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="checkbox" flt-tappable aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders a checked disabled checkbox', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasCheckedState.index |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.isChecked.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="checkbox" aria-disabled="true" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders an unchecked checkbox', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasCheckedState.index |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="checkbox" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders a checked radio button', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.hasCheckedState.index |
ui.SemanticsFlag.isInMutuallyExclusiveGroup.index |
ui.SemanticsFlag.isChecked.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="radio" flt-tappable aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders a checked disabled radio button', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.hasCheckedState.index |
ui.SemanticsFlag.isInMutuallyExclusiveGroup.index |
ui.SemanticsFlag.isChecked.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="radio" aria-disabled="true" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders an unchecked checkbox', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.hasCheckedState.index |
ui.SemanticsFlag.isInMutuallyExclusiveGroup.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="radio" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required bool isFocused }) {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
// The following combination of actions and flags describe a checkbox.
hasTap: true,
hasEnabledState: true,
isEnabled: true,
hasCheckedState: true,
isFocusable: true,
isFocused: isFocused,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
pumpSemantics(isFocused: false);
final DomElement element = owner().debugSemanticsTree![0]!.element;
expect(capturedActions, isEmpty);
pumpSemantics(isFocused: true);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
capturedActions.clear();
// The framework removes focus from the widget (i.e. "blurs" it). Since the
// blurring is initiated by the framework, there's no need to send any
// notifications back to the framework about it.
pumpSemantics(isFocused: false);
expect(capturedActions, isEmpty);
// If the element is blurred by the browser, then we do want to notify the
// framework. This is because screen reader can be focused on something
// other than what the framework is focused on, and notifying the framework
// about the loss of focus on a node is information that the framework did
// not have before.
element.blur();
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
semantics().semanticsEnabled = false;
});
}
void _testTappable() {
test('renders an enabled tappable widget', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isFocusable: true,
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>
''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.primaryRole?.role, PrimaryRole.button);
expect(
node.primaryRole?.debugSecondaryRoles,
containsAll(<Role>[Role.focusable, Role.tappable]),
);
expect(tester.getSemanticsObject(0).element.tabIndex, 0);
semantics().semanticsEnabled = false;
});
test('renders a disabled tappable widget', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.isButton.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('can switch tappable between enabled and disabled', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void updateTappable({required bool enabled}) {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
hasTap: true,
hasEnabledState: true,
isEnabled: enabled,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
updateTappable(enabled: false);
expectSemanticsTree(
owner(),
'<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>'
);
updateTappable(enabled: true);
expectSemanticsTree(
owner(),
'<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>',
);
updateTappable(enabled: false);
expectSemanticsTree(
owner(),
'<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>',
);
updateTappable(enabled: true);
expectSemanticsTree(
owner(),
'<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>',
);
semantics().semanticsEnabled = false;
});
test('focuses on tappable after element has been attached', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expect(domDocument.activeElement, tester.getSemanticsObject(0).element);
semantics().semanticsEnabled = false;
});
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required bool isFocused }) {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
// The following combination of actions and flags describe a button.
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: isFocused,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
pumpSemantics(isFocused: false);
final DomElement element = owner().debugSemanticsTree![0]!.element;
expect(capturedActions, isEmpty);
pumpSemantics(isFocused: true);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
capturedActions.clear();
pumpSemantics(isFocused: false);
expect(capturedActions, isEmpty);
element.blur();
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
semantics().semanticsEnabled = false;
});
// Regression test for: https://github.com/flutter/flutter/issues/134842
//
// If the click event is allowed to propagate through the hierarchy, then both
// the descendant and the parent will generate a SemanticsAction.tap, causing
// a double-tap to happen on the framework side.
test('inner tappable overrides ancestor tappable', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isFocusable: true,
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
isFocusable: true,
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
],
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem flt-tappable role="button" style="$rootSemanticStyle">
<sem-c>
<sem flt-tappable role="button"></sem>
</sem-c>
</sem>
''');
// Tap on the outer element
{
final DomElement element = tester.getSemanticsObject(0).element;
final DomRect rect = element.getBoundingClientRect();
element.dispatchEvent(createDomMouseEvent('click', <Object?, Object?>{
'clientX': (rect.left + (rect.right - rect.left) / 2).floor(),
'clientY': (rect.top + (rect.bottom - rect.top) / 2).floor(),
}));
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.tap, null),
]);
}
// Tap on the inner element
{
capturedActions.clear();
final DomElement element = tester.getSemanticsObject(1).element;
final DomRect rect = element.getBoundingClientRect();
element.dispatchEvent(createDomMouseEvent('click', <Object?, Object?>{
'bubbles': true,
'clientX': (rect.left + (rect.right - rect.left) / 2).floor(),
'clientY': (rect.top + (rect.bottom - rect.top) / 2).floor(),
}));
// The click on the inner element should not propagate to the parent to
// avoid sending a second SemanticsAction.tap action to the framework.
expect(capturedActions, <CapturedAction>[
(1, ui.SemanticsAction.tap, null),
]);
}
semantics().semanticsEnabled = false;
});
}
void _testImage() {
test('renders an image with no child nodes and with a label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isImage.index,
label: 'Test Image Label',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="img" aria-label="Test Image Label" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});
test('renders an image with a child node and with a label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isImage.index,
label: 'Test Image Label',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-img role="img" aria-label="Test Image Label">
</sem-img>
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
semantics().semanticsEnabled = false;
});
test('renders an image with no child nodes without a label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isImage.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(
owner(),
'<sem role="img" style="$rootSemanticStyle"></sem>',
);
semantics().semanticsEnabled = false;
});
test('renders an image with a child node and without a label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isImage.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-img role="img">
</sem-img>
<sem-c>
<sem></sem>
</sem-c>
</sem>''');
semantics().semanticsEnabled = false;
});
}
class MockAccessibilityAnnouncements implements AccessibilityAnnouncements {
int announceInvoked = 0;
@override
void announce(String message, Assertiveness assertiveness) {
announceInvoked += 1;
}
@override
DomHTMLElement ariaLiveElementFor(Assertiveness assertiveness) {
throw UnsupportedError(
'ariaLiveElementFor is not supported in MockAccessibilityAnnouncements');
}
@override
void handleMessage(StandardMessageCodec codec, ByteData? data) {
throw UnsupportedError(
'handleMessage is not supported in MockAccessibilityAnnouncements!');
}
}
void _testLiveRegion() {
tearDown(() {
LiveRegion.debugOverrideAccessibilityAnnouncements(null);
});
test('announces the label after an update', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
MockAccessibilityAnnouncements();
LiveRegion.debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'This is a snackbar',
flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 1);
semantics().semanticsEnabled = false;
});
test('does not announce anything if there is no label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
MockAccessibilityAnnouncements();
LiveRegion.debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 0);
semantics().semanticsEnabled = false;
});
test('does not announce the same label over and over', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
MockAccessibilityAnnouncements();
LiveRegion.debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);
ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'This is a snackbar',
flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 1);
builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'This is a snackbar',
flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 1);
semantics().semanticsEnabled = false;
});
}
void _testPlatformView() {
test('sets and updates aria-owns', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
// Set.
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
platformViewId: 5,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(
owner(),
'<sem aria-owns="flt-pv-5" style="$rootSemanticStyle"></sem>',
);
}
// Update.
{
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
platformViewId: 42,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(
owner(),
'<sem aria-owns="flt-pv-42" style="$rootSemanticStyle"></sem>',
);
}
semantics().semanticsEnabled = false;
});
test('is transparent w.r.t. hit testing', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
platformViewId: 5,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(
owner(),
'<sem aria-owns="flt-pv-5" style="$rootSemanticStyle"></sem>',
);
final DomElement element = owner().semanticsHost.querySelector('flt-semantics')!;
expect(element.style.pointerEvents, 'none');
semantics().semanticsEnabled = false;
});
// This test simulates the scenario of three child semantic nodes contained by
// a common parent. The first and the last nodes are plain leaf nodes. The
// middle node is a platform view node. Nodes overlap. The test hit tests
// various points and verifies that the correct DOM element receives the
// event. The test does this using `documentOrShadow.elementFromPoint`, which,
// if browsers are to be trusted, should do the same thing as if a pointer
// event landed at the given location.
//
// 0px -------------
// | |
// | | <- plain semantic node
// | 1 |
// 15px | -------------
// | | |
// 25px --| |
// | 2 | <- platform view
// | |
// 35px | -------------
// | | |
// 45px --| |
// | 3 | <- plain semantic node
// | |
// | |
// 60px -------------
test('is reachable via a hit test', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
ui_web.platformViewRegistry.registerViewFactory(
'test-platform-view',
(int viewId) => createDomHTMLDivElement()
..id = 'view-0'
..style.width = '100%'
..style.height = '100%',
);
await createPlatformView(0, 'test-platform-view');
final ui.SceneBuilder sceneBuilder = ui.SceneBuilder();
sceneBuilder.addPlatformView(
0,
offset: const ui.Offset(0, 15),
width: 20,
height: 30,
);
await renderScene(sceneBuilder.build());
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
final double dpr = EngineFlutterDisplay.instance.devicePixelRatio;
updateNode(builder,
rect: const ui.Rect.fromLTRB(0, 0, 20, 60),
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3]),
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3]),
transform: Float64List.fromList(Matrix4.diagonal3Values(dpr, dpr, 1).storage));
updateNode(
builder,
id: 1,
rect: const ui.Rect.fromLTRB(0, 0, 20, 25),
);
updateNode(
builder,
id: 2,
// This has to match the values passed to `addPlatformView` above.
rect: const ui.Rect.fromLTRB(0, 15, 20, 45),
platformViewId: 0,
);
updateNode(
builder,
id: 3,
rect: const ui.Rect.fromLTRB(0, 35, 20, 60),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2" aria-owns="flt-pv-0"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
final DomElement root = owner().semanticsHost.querySelector('#flt-semantic-node-0')!;
expect(root.style.pointerEvents, 'none');
final DomElement child1 =
owner().semanticsHost.querySelector('#flt-semantic-node-1')!;
expect(child1.style.pointerEvents, 'all');
final DomRect child1Rect = child1.getBoundingClientRect();
expect(child1Rect.left, 0);
expect(child1Rect.top, 0);
expect(child1Rect.right, 20);
expect(child1Rect.bottom, 25);
final DomElement child2 =
owner().semanticsHost.querySelector('#flt-semantic-node-2')!;
expect(child2.style.pointerEvents, 'none');
final DomRect child2Rect = child2.getBoundingClientRect();
expect(child2Rect.left, 0);
expect(child2Rect.top, 15);
expect(child2Rect.right, 20);
expect(child2Rect.bottom, 45);
final DomElement child3 =
owner().semanticsHost.querySelector('#flt-semantic-node-3')!;
expect(child3.style.pointerEvents, 'all');
final DomRect child3Rect = child3.getBoundingClientRect();
expect(child3Rect.left, 0);
expect(child3Rect.top, 35);
expect(child3Rect.right, 20);
expect(child3Rect.bottom, 60);
final DomElement platformViewElement =
platformViewsHost.querySelector('#view-0')!;
final DomRect platformViewRect =
platformViewElement.getBoundingClientRect();
expect(platformViewRect.left, 0);
expect(platformViewRect.top, 15);
expect(platformViewRect.right, 20);
expect(platformViewRect.bottom, 45);
// Hit test child 1
expect(domDocument.elementFromPoint(10, 10), child1);
// Hit test overlap between child 1 and 2
// TODO(yjbanov): this is a known limitation, see https://github.com/flutter/flutter/issues/101439
expect(domDocument.elementFromPoint(10, 20), child1);
// Hit test child 2
// Clicking at the location of the middle semantics node should allow the
// event to go through the semantic tree and hit the platform view. Since
// platform views are projected into the shadow DOM from outside the shadow
// root, it would be reachable both from the shadow root (by hitting the
// corresponding <slot> tag) and from the document (by hitting the platform
// view element itself).
// Browsers disagree about which element should be returned when hit testing
// a shadow root. However, they do agree when hit testing `document`.
//
// See:
// * https://github.com/w3c/csswg-drafts/issues/556
// * https://bugzilla.mozilla.org/show_bug.cgi?id=1502369
expect(domDocument.elementFromPoint(10, 30), platformViewElement);
// Hit test overlap between child 2 and 3
expect(domDocument.elementFromPoint(10, 40), child3);
// Hit test child 3
expect(domDocument.elementFromPoint(10, 50), child3);
semantics().semanticsEnabled = false;
});
}
void _testGroup() {
test('nodes with children and labels use group role with aria label', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'this is a label for a group of elements',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="group" aria-label="this is a label for a group of elements" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
''');
semantics().semanticsEnabled = false;
});
}
void _testDialog() {
test('renders named and labeled routes', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'this is a dialog label',
flags: 0 | ui.SemanticsFlag.scopesRoute.index | ui.SemanticsFlag.namesRoute.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="dialog" aria-label="this is a dialog label" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
''');
expect(
owner().debugSemanticsTree![0]!.primaryRole?.role,
PrimaryRole.dialog,
);
semantics().semanticsEnabled = false;
});
test('warns about missing label', () {
final List<String> warnings = <String>[];
printWarning = warnings.add;
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.scopesRoute.index | ui.SemanticsFlag.namesRoute.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[1]),
childrenInTraversalOrder: Int32List.fromList(<int>[1]),
);
updateNode(
builder,
id: 1,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
owner().updateSemantics(builder.build());
expect(
warnings,
<String>[
'Semantic node 0 had both scopesRoute and namesRoute set, indicating a self-labelled dialog, but it is missing the label. A dialog should be labelled either by setting namesRoute on itself and providing a label, or by containing a child node with namesRoute that can describe it with its content.',
],
);
// But still sets the dialog role.
expectSemanticsTree(owner(), '''
<sem role="dialog" aria-label="" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
''');
expect(
owner().debugSemanticsTree![0]!.primaryRole?.role,
PrimaryRole.dialog,
);
semantics().semanticsEnabled = false;
});
test('dialog can be described by a descendant', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required String label }) {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
scopesRoute: true,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 2,
namesRoute: true,
label: label,
),
],
),
],
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem role="dialog" aria-describedby="flt-semantic-node-2" style="$rootSemanticStyle">
<sem-c>
<sem>
<sem-c>
<sem role="text">$label</sem>
</sem-c>
</sem>
</sem-c>
</sem>
''');
}
pumpSemantics(label: 'Dialog label');
expect(
owner().debugSemanticsTree![0]!.primaryRole?.role,
PrimaryRole.dialog,
);
expect(
owner().debugSemanticsTree![2]!.primaryRole?.role,
PrimaryRole.generic,
);
expect(
owner().debugSemanticsTree![2]!.primaryRole?.debugSecondaryRoles,
contains(Role.routeName),
);
pumpSemantics(label: 'Updated dialog label');
semantics().semanticsEnabled = false;
});
test('scopesRoute alone sets the dialog role with no label', () {
final List<String> warnings = <String>[];
printWarning = warnings.add;
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
scopesRoute: true,
transform: Matrix4.identity().toFloat64(),
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle"></sem>
''');
expect(
owner().debugSemanticsTree![0]!.primaryRole?.role,
PrimaryRole.dialog,
);
expect(
owner().debugSemanticsTree![0]!.primaryRole?.secondaryRoleManagers,
isNot(contains(Role.routeName)),
);
semantics().semanticsEnabled = false;
});
test('namesRoute alone has no effect', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 2,
namesRoute: true,
label: 'Hello',
),
],
),
],
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem>
<sem-c>
<sem role="text">Hello</sem>
</sem-c>
</sem>
</sem-c>
</sem>
''');
expect(
owner().debugSemanticsTree![0]!.primaryRole?.role,
PrimaryRole.generic,
);
expect(
owner().debugSemanticsTree![2]!.primaryRole?.debugSecondaryRoles,
contains(Role.routeName),
);
semantics().semanticsEnabled = false;
});
// Test the simple scenario of a dialog coming up and containing focusable
// descendants that are not initially focused. The expectation is that the
// first descendant will be auto-focused.
test('focuses on the first unfocused Focusable', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
scopesRoute: true,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
// None of the children should have isFocused set to `true` to make
// sure that the auto-focus logic kicks in.
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 2,
label: 'Button 1',
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: false,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
tester.updateNode(
id: 3,
label: 'Button 2',
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: false,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
],
),
],
);
tester.apply();
expect(
capturedActions,
<CapturedAction>[
(2, ui.SemanticsAction.didGainAccessibilityFocus, null),
],
);
semantics().semanticsEnabled = false;
});
// Test the scenario of a dialog coming up and containing focusable
// descendants with one of them explicitly requesting focus. The expectation
// is that the dialog will not attempt to auto-focus on anything and let the
// respective descendant take focus.
test('does nothing if a descendant asks for focus explicitly', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
scopesRoute: true,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 2,
label: 'Button 1',
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: false,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
tester.updateNode(
id: 3,
label: 'Button 2',
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
// Asked for focus explicitly.
isFocused: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
],
),
],
);
tester.apply();
expect(
capturedActions,
<CapturedAction>[
(3, ui.SemanticsAction.didGainAccessibilityFocus, null),
],
);
semantics().semanticsEnabled = false;
});
// Test the scenario of a dialog coming up and containing non-focusable
// descendants that can have a11y focus. The expectation is that the first
// descendant will be auto-focused, even if it's not input-focusable.
test('focuses on the first non-focusable descedant', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
scopesRoute: true,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 2,
label: 'Heading',
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
tester.updateNode(
id: 3,
label: 'Click me!',
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: false,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
],
),
],
);
tester.apply();
// The focused node is not focusable, so no notification is sent to the
// framework.
expect(capturedActions, isEmpty);
// However, the element should have gotten the focus.
final DomElement element = owner().debugSemanticsTree![2]!.element;
expect(element.tabIndex, -1);
expect(domDocument.activeElement, element);
semantics().semanticsEnabled = false;
});
// This mostly makes sure the engine doesn't crash if given a completely empty
// dialog trying to find something to focus on.
test('does nothing if nothing is focusable inside the dialog', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
scopesRoute: true,
transform: Matrix4.identity().toFloat64(),
);
tester.apply();
expect(capturedActions, isEmpty);
expect(domDocument.activeElement, domDocument.body);
semantics().semanticsEnabled = false;
});
}
typedef CapturedAction = (int nodeId, ui.SemanticsAction action, Object? args);
void _testFocusable() {
test('AccessibilityFocusManager can manage element focus', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics() {
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'Dummy root element',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[]),
childrenInTraversalOrder: Int32List.fromList(<int>[]),
);
owner().updateSemantics(builder.build());
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
capturedActions.add((event.nodeId, event.type, event.arguments));
};
expect(capturedActions, isEmpty);
final AccessibilityFocusManager manager = AccessibilityFocusManager(owner());
expect(capturedActions, isEmpty);
final DomElement element = createDomElement('test-element');
expect(element.tabIndex, -1);
domDocument.body!.append(element);
// Start managing element
manager.manage(1, element);
expect(element.tabIndex, 0);
expect(capturedActions, isEmpty);
expect(domDocument.activeElement, isNot(element));
// Request focus
manager.changeFocus(true);
pumpSemantics(); // triggers post-update callbacks
expect(domDocument.activeElement, element);
expect(capturedActions, <CapturedAction>[
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
capturedActions.clear();
// Give up focus
manager.changeFocus(false);
pumpSemantics(); // triggers post-update callbacks
expect(capturedActions, isEmpty);
expect(domDocument.activeElement, element);
// Browser blurs the element
element.blur();
expect(domDocument.activeElement, isNot(element));
expect(capturedActions, <CapturedAction>[
(1, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
capturedActions.clear();
// Request focus again
manager.changeFocus(true);
pumpSemantics(); // triggers post-update callbacks
expect(domDocument.activeElement, element);
expect(capturedActions, <CapturedAction>[
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
capturedActions.clear();
// Double-request focus
manager.changeFocus(true);
pumpSemantics(); // triggers post-update callbacks
expect(domDocument.activeElement, element);
expect(
reason: 'Nothing should be sent to the framework on focus re-request.',
capturedActions, isEmpty);
capturedActions.clear();
// Stop managing
manager.stopManaging();
pumpSemantics(); // triggers post-update callbacks
expect(
reason: 'There should be no notification to the framework because the '
'framework should already know. Otherwise, it would not have '
'asked to stop managing the node.',
capturedActions,
isEmpty,
);
expect(domDocument.activeElement, element);
// Attempt to request focus when not managing an element.
element.blur();
manager.changeFocus(true);
pumpSemantics(); // triggers post-update callbacks
expect(
reason: 'Attempting to request focus on a node that is not managed should '
'not result in any notifications to the framework.',
capturedActions,
isEmpty,
);
expect(domDocument.activeElement, isNot(element));
semantics().semanticsEnabled = false;
});
test('applies generic Focusable role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
{
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
label: 'focusable text',
isFocusable: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
],
);
tester.apply();
}
expectSemanticsTree(owner(), '''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text">focusable text</sem>
</sem-c>
</sem>
''');
final SemanticsObject node = owner().debugSemanticsTree![1]!;
expect(node.isFocusable, isTrue);
expect(
node.primaryRole?.role,
PrimaryRole.generic,
);
expect(
node.primaryRole?.debugSecondaryRoles,
contains(Role.focusable),
);
final DomElement element = node.element;
expect(domDocument.activeElement, isNot(element));
{
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 1,
label: 'test focusable',
isFocusable: true,
isFocused: true,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
expect(domDocument.activeElement, element);
semantics().semanticsEnabled = false;
});
}
void _testLink() {
test('nodes with link: true creates anchor tag', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isLink: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.element.tagName.toLowerCase(), 'a');
});
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
void updateNode(
ui.SemanticsUpdateBuilder builder, {
int id = 0,
int flags = 0,
int actions = 0,
int maxValueLength = 0,
int currentValueLength = 0,
int textSelectionBase = 0,
int textSelectionExtent = 0,
int platformViewId = -1, // -1 means not a platform view
int scrollChildren = 0,
int scrollIndex = 0,
double scrollPosition = 0.0,
double scrollExtentMax = 0.0,
double scrollExtentMin = 0.0,
double elevation = 0.0,
double thickness = 0.0,
ui.Rect rect = ui.Rect.zero,
String identifier = '',
String label = '',
List<ui.StringAttribute> labelAttributes = const <ui.StringAttribute>[],
String hint = '',
List<ui.StringAttribute> hintAttributes = const <ui.StringAttribute>[],
String value = '',
List<ui.StringAttribute> valueAttributes = const <ui.StringAttribute>[],
String increasedValue = '',
List<ui.StringAttribute> increasedValueAttributes =
const <ui.StringAttribute>[],
String decreasedValue = '',
List<ui.StringAttribute> decreasedValueAttributes =
const <ui.StringAttribute>[],
String tooltip = '',
ui.TextDirection textDirection = ui.TextDirection.ltr,
Float64List? transform,
Int32List? childrenInTraversalOrder,
Int32List? childrenInHitTestOrder,
Int32List? additionalActions,
}) {
transform ??= Float64List.fromList(Matrix4.identity().storage);
childrenInTraversalOrder ??= Int32List(0);
childrenInHitTestOrder ??= Int32List(0);
additionalActions ??= Int32List(0);
builder.updateNode(
id: id,
flags: flags,
actions: actions,
maxValueLength: maxValueLength,
currentValueLength: currentValueLength,
textSelectionBase: textSelectionBase,
textSelectionExtent: textSelectionExtent,
platformViewId: platformViewId,
scrollChildren: scrollChildren,
scrollIndex: scrollIndex,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
elevation: elevation,
thickness: thickness,
rect: rect,
identifier: identifier,
label: label,
labelAttributes: labelAttributes,
hint: hint,
hintAttributes: hintAttributes,
value: value,
valueAttributes: valueAttributes,
increasedValue: increasedValue,
increasedValueAttributes: increasedValueAttributes,
decreasedValue: decreasedValue,
decreasedValueAttributes: decreasedValueAttributes,
tooltip: tooltip,
textDirection: textDirection,
transform: transform,
childrenInTraversalOrder: childrenInTraversalOrder,
childrenInHitTestOrder: childrenInHitTestOrder,
additionalActions: additionalActions,
);
}
const MethodCodec codec = StandardMethodCodec();
/// Sends a platform message to create a Platform View with the given id and viewType.
Future<void> createPlatformView(int id, String viewType) {
final Completer<void> completer = Completer<void>();
ui.PlatformDispatcher.instance.sendPlatformMessage(
'flutter/platform_views',
codec.encodeMethodCall(MethodCall(
'create',
<String, dynamic>{
'id': id,
'viewType': viewType,
},
)),
(dynamic _) => completer.complete(),
);
return completer.future;
}