// Copyright 2014 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:math'; import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'semantics_tester.dart'; void main() { setUp(() { debugResetSemanticsIdCounter(); }); testWidgets('Semantics shutdown and restart', (WidgetTester tester) async { SemanticsTester? semantics = SemanticsTester(tester); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(label: 'test1', textDirection: TextDirection.ltr), ], ); await tester.pumpWidget( Semantics(label: 'test1', textDirection: TextDirection.ltr, child: Container()), ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); semantics = null; expect(tester.binding.hasScheduledFrame, isFalse); semantics = SemanticsTester(tester); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }, semanticsEnabled: false); testWidgets('Semantics tag only applies to immediate child', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: ListView( children: [ SingleChildScrollView( child: Column( children: [ Container(padding: const EdgeInsets.only(top: 20.0)), const Text('label'), ], ), ), ], ), ), ); expect( semantics, isNot( includesNodeWith( flags: [SemanticsFlag.hasImplicitScrolling], tags: {RenderViewport.useTwoPaneSemantics}, ), ), ); await tester.pump(); // Semantics should stay the same after a frame update. expect( semantics, isNot( includesNodeWith( flags: [SemanticsFlag.hasImplicitScrolling], tags: {RenderViewport.useTwoPaneSemantics}, ), ), ); semantics.dispose(); }, semanticsEnabled: false); testWidgets('Semantics tooltip', (WidgetTester tester) async { final semantics = SemanticsTester(tester); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(tooltip: 'test1', textDirection: TextDirection.ltr), ], ); await tester.pumpWidget(Semantics(tooltip: 'test1', textDirection: TextDirection.ltr)); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Detach and reattach assert', (WidgetTester tester) async { final semantics = SemanticsTester(tester); final GlobalKey key = GlobalKey(); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( label: 'test1', child: Semantics(key: key, container: true, label: 'test2a', child: Container()), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( label: 'test1', children: [TestSemantics(label: 'test2a')], ), ], ), ignoreId: true, ignoreRect: true, ignoreTransform: true, ), ); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( label: 'test1', child: Semantics( container: true, label: 'middle', child: Semantics(key: key, container: true, label: 'test2b', child: Container()), ), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( label: 'test1', children: [ TestSemantics( label: 'middle', children: [TestSemantics(label: 'test2b')], ), ], ), ], ), ignoreId: true, ignoreRect: true, ignoreTransform: true, ), ); semantics.dispose(); }); testWidgets('different locale will not merge', (WidgetTester tester) async { final semantics = SemanticsTester(tester); final GlobalKey key = GlobalKey(); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( localeForSubtree: const Locale('AB', 'CD'), child: Semantics( key: key, localeForSubtree: const Locale('DE', 'FG'), child: const SizedBox(width: 10, height: 10), ), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( locale: const Locale('AB', 'CD'), children: [TestSemantics(locale: const Locale('DE', 'FG'))], ), ], ), ignoreId: true, ignoreRect: true, ignoreTransform: true, ), ); semantics.dispose(); }); testWidgets('Semantics and Directionality - RTL', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.rtl, child: Semantics(label: 'test1', child: Container()), ), ); expect(semantics, includesNodeWith(label: 'test1', textDirection: TextDirection.rtl)); semantics.dispose(); }); testWidgets('Semantics and Directionality - LTR', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics(label: 'test1', child: Container()), ), ); expect(semantics, includesNodeWith(label: 'test1', textDirection: TextDirection.ltr)); semantics.dispose(); }); testWidgets('Semantics and Directionality - cannot override RTL with LTR', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(label: 'test1', textDirection: TextDirection.ltr), ], ); await tester.pumpWidget( Directionality( textDirection: TextDirection.rtl, child: Semantics(label: 'test1', textDirection: TextDirection.ltr, child: Container()), ), ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics and Directionality - cannot override LTR with RTL', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(label: 'test1', textDirection: TextDirection.rtl), ], ); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics(label: 'test1', textDirection: TextDirection.rtl, child: Container()), ), ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics label and hint', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics(label: 'label', hint: 'hint', value: 'value', child: Container()), ), ); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild( label: 'label', hint: 'hint', value: 'value', textDirection: TextDirection.ltr, ), ], ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics hints can merge', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( container: true, child: Column( children: [ Semantics(hint: 'hint one'), Semantics(hint: 'hint two'), ], ), ), ), ); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(hint: 'hint one\nhint two', textDirection: TextDirection.ltr), ], ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics values do not merge', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( container: true, child: Column( children: [ Semantics(value: 'value one', child: const SizedBox(height: 10.0, width: 10.0)), Semantics(value: 'value two', child: const SizedBox(height: 10.0, width: 10.0)), ], ), ), ), ); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild( children: [ TestSemantics(value: 'value one', textDirection: TextDirection.ltr), TestSemantics(value: 'value two', textDirection: TextDirection.ltr), ], ), ], ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics value and hint can merge', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( container: true, child: Column( children: [ Semantics(hint: 'hint'), Semantics(value: 'value'), ], ), ), ), ); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(hint: 'hint', value: 'value', textDirection: TextDirection.ltr), ], ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics tagForChildren works', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( container: true, tagForChildren: const SemanticsTag('custom tag'), child: Column( children: [ Semantics(container: true, child: const Text('child 1')), Semantics(container: true, child: const Text('child 2')), ], ), ), ), ); final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild( children: [ TestSemantics( label: 'child 1', tags: [const SemanticsTag('custom tag')], textDirection: TextDirection.ltr, ), TestSemantics( label: 'child 2', tags: [const SemanticsTag('custom tag')], textDirection: TextDirection.ltr, ), ], ), ], ); expect( semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true, ignoreId: true), ); semantics.dispose(); }); testWidgets('Semantics widget supports all actions', (WidgetTester tester) async { final semantics = SemanticsTester(tester); final performedActions = []; await tester.pumpWidget( Semantics( container: true, onDismiss: () => performedActions.add(SemanticsAction.dismiss), onTap: () => performedActions.add(SemanticsAction.tap), onLongPress: () => performedActions.add(SemanticsAction.longPress), onScrollLeft: () => performedActions.add(SemanticsAction.scrollLeft), onScrollRight: () => performedActions.add(SemanticsAction.scrollRight), onScrollUp: () => performedActions.add(SemanticsAction.scrollUp), onScrollDown: () => performedActions.add(SemanticsAction.scrollDown), onIncrease: () => performedActions.add(SemanticsAction.increase), onDecrease: () => performedActions.add(SemanticsAction.decrease), onCopy: () => performedActions.add(SemanticsAction.copy), onCut: () => performedActions.add(SemanticsAction.cut), onPaste: () => performedActions.add(SemanticsAction.paste), onMoveCursorForwardByCharacter: (bool _) => performedActions.add(SemanticsAction.moveCursorForwardByCharacter), onMoveCursorBackwardByCharacter: (bool _) => performedActions.add(SemanticsAction.moveCursorBackwardByCharacter), onSetSelection: (TextSelection _) => performedActions.add(SemanticsAction.setSelection), onSetText: (String _) => performedActions.add(SemanticsAction.setText), onDidGainAccessibilityFocus: () => performedActions.add(SemanticsAction.didGainAccessibilityFocus), onDidLoseAccessibilityFocus: () => performedActions.add(SemanticsAction.didLoseAccessibilityFocus), onFocus: () => performedActions.add(SemanticsAction.focus), onExpand: () => performedActions.add(SemanticsAction.expand), onCollapse: () => performedActions.add(SemanticsAction.collapse), ), ); final Set allActions = SemanticsAction.values.toSet() ..remove(SemanticsAction.moveCursorForwardByWord) ..remove(SemanticsAction.moveCursorBackwardByWord) ..remove(SemanticsAction.customAction) // customAction is not user-exposed. ..remove(SemanticsAction.showOnScreen) // showOnScreen is not user-exposed ..remove(SemanticsAction.scrollToOffset); // scrollToOffset is not user-exposed const expectedId = 1; final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild( id: expectedId, rect: TestSemantics.fullScreen, actions: allActions.fold( 0, (int previous, SemanticsAction action) => previous | action.index, ), ), ], ); expect(semantics, hasSemantics(expectedSemantics)); // Do the actions work? final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; var expectedLength = 1; for (final action in allActions) { switch (action) { case SemanticsAction.moveCursorBackwardByCharacter: case SemanticsAction.moveCursorForwardByCharacter: semanticsOwner.performAction(expectedId, action, true); case SemanticsAction.setSelection: semanticsOwner.performAction(expectedId, action, { 'base': 4, 'extent': 5, }); case SemanticsAction.setText: semanticsOwner.performAction(expectedId, action, 'text'); case SemanticsAction.copy: case SemanticsAction.customAction: case SemanticsAction.cut: case SemanticsAction.decrease: case SemanticsAction.didGainAccessibilityFocus: case SemanticsAction.didLoseAccessibilityFocus: case SemanticsAction.dismiss: case SemanticsAction.increase: case SemanticsAction.longPress: case SemanticsAction.moveCursorBackwardByWord: case SemanticsAction.moveCursorForwardByWord: case SemanticsAction.paste: case SemanticsAction.scrollDown: case SemanticsAction.scrollLeft: case SemanticsAction.scrollRight: case SemanticsAction.scrollUp: case SemanticsAction.scrollToOffset: case SemanticsAction.showOnScreen: case SemanticsAction.tap: case SemanticsAction.focus: case SemanticsAction.expand: case SemanticsAction.collapse: semanticsOwner.performAction(expectedId, action); } expect(performedActions.length, expectedLength); expect(performedActions.last, action); expectedLength += 1; } semantics.dispose(); }); testWidgets('Semantics widget supports all flags', (WidgetTester tester) async { final semantics = SemanticsTester(tester); // Checked state and toggled state are mutually exclusive. // Focused and isAccessibilityFocusBlocked are mutually exclusive. await tester.pumpWidget( Semantics( key: const Key('a'), container: true, explicitChildNodes: true, // flags enabled: true, hidden: true, checked: true, selected: true, button: true, slider: true, keyboardKey: true, link: true, textField: true, readOnly: true, focused: true, focusable: true, inMutuallyExclusiveGroup: true, header: true, obscured: true, multiline: true, scopesRoute: true, namesRoute: true, image: true, liveRegion: true, expanded: true, isRequired: true, ), ); var flags = SemanticsFlags( isChecked: CheckedState.isTrue, isSelected: Tristate.isTrue, isEnabled: Tristate.isTrue, isExpanded: Tristate.isTrue, isRequired: Tristate.isTrue, isFocused: Tristate.isTrue, isButton: true, isTextField: true, isInMutuallyExclusiveGroup: true, isObscured: true, scopesRoute: true, namesRoute: true, isHidden: true, isImage: true, isHeader: true, isLiveRegion: true, isMultiline: true, isReadOnly: true, isLink: true, isSlider: true, isKeyboardKey: true, ); var expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(rect: TestSemantics.fullScreen, flags: flags), ], ); expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); await tester.pumpWidget(Semantics(key: const Key('b'), container: true, scopesRoute: false)); expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(rect: TestSemantics.fullScreen, flags: []), ], ); expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); await tester.pumpWidget(Semantics(key: const Key('c'), toggled: true)); expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild( rect: TestSemantics.fullScreen, flags: [SemanticsFlag.hasToggledState, SemanticsFlag.isToggled], ), ], ); expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); await tester.pumpWidget( Semantics( key: const Key('a'), container: true, explicitChildNodes: true, // flags enabled: true, hidden: true, checked: false, mixed: true, selected: true, button: true, slider: true, keyboardKey: true, link: true, textField: true, readOnly: true, focused: true, focusable: true, inMutuallyExclusiveGroup: true, header: true, obscured: true, multiline: true, scopesRoute: true, namesRoute: true, image: true, liveRegion: true, expanded: true, isRequired: true, ), ); flags = flags.copyWith(isChecked: CheckedState.mixed); semantics.dispose(); expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild(rect: TestSemantics.fullScreen, flags: flags), ], ); expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); }); testWidgets('Actions can be replaced without triggering semantics update', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); var semanticsUpdateCount = 0; tester.binding.pipelineOwner.semanticsOwner!.addListener(() { semanticsUpdateCount += 1; }); final performedActions = []; await tester.pumpWidget(Semantics(container: true, onTap: () => performedActions.add('first'))); const expectedId = 1; final expectedSemantics = TestSemantics.root( children: [ TestSemantics.rootChild( id: expectedId, rect: TestSemantics.fullScreen, actions: SemanticsAction.tap.index, ), ], ); final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; expect(semantics, hasSemantics(expectedSemantics)); semanticsOwner.performAction(expectedId, SemanticsAction.tap); expect(semanticsUpdateCount, 1); expect(performedActions, ['first']); semanticsUpdateCount = 0; performedActions.clear(); // Updating existing handler should not trigger semantics update await tester.pumpWidget( Semantics(container: true, onTap: () => performedActions.add('second')), ); expect(semantics, hasSemantics(expectedSemantics)); semanticsOwner.performAction(expectedId, SemanticsAction.tap); expect(semanticsUpdateCount, 0); expect(performedActions, ['second']); semanticsUpdateCount = 0; performedActions.clear(); // Adding a handler works await tester.pumpWidget( Semantics( container: true, onTap: () => performedActions.add('second'), onLongPress: () => performedActions.add('longPress'), ), ); final expectedSemanticsWithLongPress = TestSemantics.root( children: [ TestSemantics.rootChild( id: expectedId, rect: TestSemantics.fullScreen, actions: SemanticsAction.tap.index | SemanticsAction.longPress.index, ), ], ); expect(semantics, hasSemantics(expectedSemanticsWithLongPress)); semanticsOwner.performAction(expectedId, SemanticsAction.longPress); expect(semanticsUpdateCount, 1); expect(performedActions, ['longPress']); semanticsUpdateCount = 0; performedActions.clear(); // Removing a handler works await tester.pumpWidget( Semantics(container: true, onTap: () => performedActions.add('second')), ); expect(semantics, hasSemantics(expectedSemantics)); expect(semanticsUpdateCount, 1); semantics.dispose(); }); testWidgets('onTapHint and onLongPressHint create custom actions', (WidgetTester tester) async { final SemanticsHandle semantics = tester.ensureSemantics(); await tester.pumpWidget(Semantics(container: true, onTap: () {}, onTapHint: 'test')); expect( tester.getSemantics(find.byType(Semantics)), matchesSemantics(hasTapAction: true, onTapHint: 'test'), ); await tester.pumpWidget(Semantics(container: true, onLongPress: () {}, onLongPressHint: 'foo')); expect( tester.getSemantics(find.byType(Semantics)), matchesSemantics(hasLongPressAction: true, onLongPressHint: 'foo'), ); semantics.dispose(); }); testWidgets('CustomSemanticsActions can be added to a Semantics widget', ( WidgetTester tester, ) async { final SemanticsHandle semantics = tester.ensureSemantics(); await tester.pumpWidget( Semantics( container: true, customSemanticsActions: { const CustomSemanticsAction(label: 'foo'): () {}, const CustomSemanticsAction(label: 'bar'): () {}, }, ), ); expect( tester.getSemantics(find.byType(Semantics)), matchesSemantics( customActions: [ const CustomSemanticsAction(label: 'bar'), const CustomSemanticsAction(label: 'foo'), ], ), ); semantics.dispose(); }); testWidgets('MergeSemantics merges CustomSemanticsActions from its children', ( WidgetTester tester, ) async { final SemanticsHandle semantics = tester.ensureSemantics(); await tester.pumpWidget( MergeSemantics( child: Column( children: [ Semantics( container: true, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, }, child: const SizedBox(width: 10, height: 10), ), Semantics( container: true, customSemanticsActions: { const CustomSemanticsAction(label: 'action2'): () {}, }, child: const SizedBox(width: 10, height: 10), ), ], ), ), ); expect( tester.getSemantics(find.byType(MergeSemantics)), matchesSemantics( customActions: [ const CustomSemanticsAction(label: 'action1'), const CustomSemanticsAction(label: 'action2'), ], ), ); semantics.dispose(); }); testWidgets('AccessiblityFocusBlockType.blockSubtree is applied to the subtree,', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Semantics( container: true, accessibilityFocusBlockType: AccessibilityFocusBlockType.blockSubtree, child: Column( children: [ // If the child set blockSubTreeAccessibilityFocus to `none`, it's still blcok because its parent. Semantics( container: true, accessibilityFocusBlockType: AccessibilityFocusBlockType.none, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, }, child: const SizedBox(width: 10, height: 10), ), // If the child doesn't have a value for accessibilityFocusable, it will also use the parent data. Semantics( container: true, customSemanticsActions: { const CustomSemanticsAction(label: 'action2'): () {}, }, child: const SizedBox(width: 10, height: 10), ), ], ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics( id: 1, flags: SemanticsFlags(isAccessibilityFocusBlocked: true), children: [ TestSemantics( id: 2, flags: SemanticsFlags(isAccessibilityFocusBlocked: true), actions: [SemanticsAction.customAction], ), TestSemantics( id: 3, flags: SemanticsFlags(isAccessibilityFocusBlocked: true), actions: [SemanticsAction.customAction], ), ], ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('AccessiblityFocusBlockType.blockNode is not applied to the subtree,', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Semantics( container: true, accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, child: Column( children: [ Semantics( container: true, accessibilityFocusBlockType: AccessibilityFocusBlockType.none, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, }, child: const SizedBox(width: 10, height: 10), ), Semantics( container: true, customSemanticsActions: { const CustomSemanticsAction(label: 'action2'): () {}, }, child: const SizedBox(width: 10, height: 10), ), ], ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics( id: 1, flags: SemanticsFlags(isAccessibilityFocusBlocked: true), children: [ TestSemantics(id: 2, actions: [SemanticsAction.customAction]), TestSemantics(id: 3, actions: [SemanticsAction.customAction]), ], ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('MergeSemantics merges children with AccessiblityFocusBlockType.blockNode', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: MergeSemantics( child: Column( children: [ Semantics( container: true, accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, label: 'node1', child: const SizedBox(width: 10, height: 10), ), Semantics( container: true, label: 'node2', child: const SizedBox(width: 10, height: 10), ), ], ), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics( id: 1, flags: SemanticsFlags(isAccessibilityFocusBlocked: true), label: 'node1\nnode2', ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('AccessiblityFocusBlockType.blockNode doesnt merge up or merge down', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( label: 'root', child: Semantics( accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, label: 'semantics label 0', child: Column( children: [ Semantics(label: 'semantics label 1', child: const SizedBox(width: 10, height: 10)), Semantics(label: 'semantics label 2', child: const SizedBox(width: 10, height: 10)), ], ), ), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics( id: 1, label: 'root', children: [ TestSemantics( id: 2, label: 'semantics label 0', flags: SemanticsFlags(isAccessibilityFocusBlocked: true), children: [ TestSemantics(id: 3, label: 'semantics label 1'), TestSemantics(id: 4, label: 'semantics label 2'), ], ), ], ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('AccessiblityFocusBlockType.blockSubtree doesnt merge up', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( label: 'root', child: Semantics( accessibilityFocusBlockType: AccessibilityFocusBlockType.blockSubtree, label: 'semantics label 0', child: Column( children: [ Semantics(label: 'semantics label 1', child: const SizedBox(width: 10, height: 10)), Semantics(label: 'semantics label 2', child: const SizedBox(width: 10, height: 10)), ], ), ), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics( id: 1, label: 'root', children: [ TestSemantics( id: 2, label: 'semantics label 0\nsemantics label 1\nsemantics label 2', flags: SemanticsFlags(isAccessibilityFocusBlocked: true), ), ], ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('semantics node cant be keyboard focusable but accessibility unfocusable', ( WidgetTester tester, ) async { await tester.pumpWidget( Semantics( container: true, accessibilityFocusBlockType: AccessibilityFocusBlockType.blockSubtree, focused: true, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, }, child: const SizedBox(width: 10, height: 10), ), ); final Object? exception = tester.takeException(); expect(exception, isFlutterError); final error = exception! as FlutterError; expect( error.message, startsWith('A node that is keyboard focusable cannot be set to accessibility unfocusable'), ); }); testWidgets('Increased/decreased values are annotated', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( container: true, value: '10s', increasedValue: '11s', decreasedValue: '9s', onIncrease: () => () {}, onDecrease: () => () {}, ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( actions: SemanticsAction.increase.index | SemanticsAction.decrease.index, textDirection: TextDirection.ltr, value: '10s', increasedValue: '11s', decreasedValue: '9s', ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('Semantics widgets built in a widget tree are sorted properly', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); var semanticsUpdateCount = 0; tester.binding.pipelineOwner.semanticsOwner!.addListener(() { semanticsUpdateCount += 1; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Semantics( sortKey: const CustomSortKey(0.0), explicitChildNodes: true, child: Column( children: [ Semantics(sortKey: const CustomSortKey(3.0), child: const Text('Label 1')), Semantics(sortKey: const CustomSortKey(2.0), child: const Text('Label 2')), Semantics( sortKey: const CustomSortKey(1.0), explicitChildNodes: true, child: Row( children: [ Semantics(sortKey: const OrdinalSortKey(3.0), child: const Text('Label 3')), Semantics(sortKey: const OrdinalSortKey(2.0), child: const Text('Label 4')), Semantics(sortKey: const OrdinalSortKey(1.0), child: const Text('Label 5')), ], ), ), ], ), ), ), ); expect(semanticsUpdateCount, 1); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics( id: 1, children: [ TestSemantics( id: 4, children: [ TestSemantics(id: 7, label: r'Label 5', textDirection: TextDirection.ltr), TestSemantics(id: 6, label: r'Label 4', textDirection: TextDirection.ltr), TestSemantics(id: 5, label: r'Label 3', textDirection: TextDirection.ltr), ], ), TestSemantics(id: 3, label: r'Label 2', textDirection: TextDirection.ltr), TestSemantics(id: 2, label: r'Label 1', textDirection: TextDirection.ltr), ], ), ], ), ignoreTransform: true, ignoreRect: true, ), ); semantics.dispose(); }); testWidgets('Semantics widgets built with explicit sort orders are sorted properly', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); var semanticsUpdateCount = 0; tester.binding.pipelineOwner.semanticsOwner!.addListener(() { semanticsUpdateCount += 1; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Row( children: [ Semantics(sortKey: const CustomSortKey(3.0), child: const Text('Label 1')), Semantics(sortKey: const CustomSortKey(1.0), child: const Text('Label 2')), Semantics(sortKey: const CustomSortKey(2.0), child: const Text('Label 3')), ], ), ), ); expect(semanticsUpdateCount, 1); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics(id: 2, label: r'Label 2', textDirection: TextDirection.ltr), TestSemantics(id: 3, label: r'Label 3', textDirection: TextDirection.ltr), TestSemantics(id: 1, label: r'Label 1', textDirection: TextDirection.ltr), ], ), ignoreTransform: true, ignoreRect: true, ), ); semantics.dispose(); }); testWidgets('Semantics widgets without sort orders are sorted properly', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); var semanticsUpdateCount = 0; tester.binding.pipelineOwner.semanticsOwner!.addListener(() { semanticsUpdateCount += 1; }); await tester.pumpWidget( const Directionality( textDirection: TextDirection.ltr, child: Column( children: [ Text('Label 1'), Text('Label 2'), Row(children: [Text('Label 3'), Text('Label 4'), Text('Label 5')]), ], ), ), ); expect(semanticsUpdateCount, 1); expect( semantics, hasSemantics( TestSemantics( children: [ TestSemantics(label: r'Label 1', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 2', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 3', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 4', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 5', textDirection: TextDirection.ltr), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets('Semantics widgets that are transformed are sorted properly', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); var semanticsUpdateCount = 0; tester.binding.pipelineOwner.semanticsOwner!.addListener(() { semanticsUpdateCount += 1; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Column( children: [ const Text('Label 1'), const Text('Label 2'), Transform.rotate( angle: pi / 2.0, child: const Row( children: [Text('Label 3'), Text('Label 4'), Text('Label 5')], ), ), ], ), ), ); expect(semanticsUpdateCount, 1); expect( semantics, hasSemantics( TestSemantics( children: [ TestSemantics(label: r'Label 1', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 2', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 3', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 4', textDirection: TextDirection.ltr), TestSemantics(label: r'Label 5', textDirection: TextDirection.ltr), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }); testWidgets( 'Semantics widgets without sort orders are sorted properly when no Directionality is present', (WidgetTester tester) async { final semantics = SemanticsTester(tester); var semanticsUpdateCount = 0; tester.binding.pipelineOwner.semanticsOwner!.addListener(() { semanticsUpdateCount += 1; }); await tester.pumpWidget( Stack( alignment: Alignment.center, children: [ // Set this up so that the placeholder takes up the whole screen, // and place the positioned boxes so that if we traverse in the // geometric order, we would go from box [4, 3, 2, 1, 0], but if we // go in child order, then we go from box [4, 1, 2, 3, 0]. We're verifying // that we go in child order here, not geometric order, since there // is no directionality, so we don't have a geometric opinion about // horizontal order. We do still want to sort vertically, however, // which is why the order isn't [0, 1, 2, 3, 4]. Semantics(button: true, child: const Placeholder()), Positioned( top: 200.0, left: 100.0, child: Semantics( // Box 0 button: true, child: const SizedBox(width: 30.0, height: 30.0), ), ), Positioned( top: 100.0, left: 200.0, child: Semantics( // Box 1 button: true, child: const SizedBox(width: 30.0, height: 30.0), ), ), Positioned( top: 100.0, left: 100.0, child: Semantics( // Box 2 button: true, child: const SizedBox(width: 30.0, height: 30.0), ), ), Positioned( top: 100.0, left: 0.0, child: Semantics( // Box 3 button: true, child: const SizedBox(width: 30.0, height: 30.0), ), ), Positioned( top: 10.0, left: 100.0, child: Semantics( // Box 4 button: true, child: const SizedBox(width: 30.0, height: 30.0), ), ), ], ), ); expect(semanticsUpdateCount, 1); expect( semantics, hasSemantics( TestSemantics( children: [ TestSemantics(flags: [SemanticsFlag.isButton]), TestSemantics(flags: [SemanticsFlag.isButton]), TestSemantics(flags: [SemanticsFlag.isButton]), TestSemantics(flags: [SemanticsFlag.isButton]), TestSemantics(flags: [SemanticsFlag.isButton]), TestSemantics(flags: [SemanticsFlag.isButton]), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, ), ); semantics.dispose(); }, ); testWidgets('Semantics excludeSemantics ignores children', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Semantics( label: 'label', excludeSemantics: true, textDirection: TextDirection.ltr, child: Semantics(label: 'other label', textDirection: TextDirection.ltr), ), ); expect( semantics, hasSemantics( TestSemantics( children: [ TestSemantics(label: 'label', textDirection: TextDirection.ltr), ], ), ignoreId: true, ignoreRect: true, ignoreTransform: true, ), ); semantics.dispose(); }); testWidgets('Can change handlers', (WidgetTester tester) async { await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onTap: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasTapAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onDismiss: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasDismissAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onLongPress: () {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasLongPressAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onScrollLeft: () {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasScrollLeftAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onScrollRight: () {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasScrollRightAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onScrollUp: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasScrollUpAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onScrollDown: () {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasScrollDownAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onIncrease: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasIncreaseAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onDecrease: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasDecreaseAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onCopy: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasCopyAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onCut: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasCutAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr, onPaste: () {}), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasPasteAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onSetSelection: (TextSelection _) {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', hasSetSelectionAction: true, textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onDidGainAccessibilityFocus: () {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics( label: 'foo', hasDidGainAccessibilityFocusAction: true, textDirection: TextDirection.ltr, ), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); await tester.pumpWidget( Semantics( container: true, label: 'foo', textDirection: TextDirection.ltr, onDidLoseAccessibilityFocus: () {}, ), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics( label: 'foo', hasDidLoseAccessibilityFocusAction: true, textDirection: TextDirection.ltr, ), ); await tester.pumpWidget( Semantics(container: true, label: 'foo', textDirection: TextDirection.ltr), ); expect( tester.getSemantics(find.bySemanticsLabel('foo')), matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), ); }); testWidgets('Semantics with zero transform gets dropped', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/110671. // Construct a widget tree that will end up with a fitted box that applies // a zero transform because it does not actually draw its children. // Assert that this subtree gets dropped (the root node has no children). await tester.pumpWidget( const Column( children: [ SizedBox( height: 0, width: 500, child: FittedBox( child: SizedBox( height: 55, width: 266, child: SingleChildScrollView(child: Column()), ), ), ), ], ), ); final SemanticsNode node = RendererBinding.instance.renderView.debugSemantics!; expect(node.transform, null); // Make sure the zero transform didn't end up on the root somehow. expect(node.childrenCount, 0); }); testWidgets('blocking user interaction works on explicit child node.', ( WidgetTester tester, ) async { final key1 = UniqueKey(); final key2 = UniqueKey(); await tester.pumpWidget( MaterialApp( home: Semantics( blockUserActions: true, explicitChildNodes: true, child: Column( children: [ Semantics( key: key1, label: 'label1', onTap: () {}, child: const SizedBox(width: 10, height: 10), ), Semantics( key: key2, label: 'label2', onTap: () {}, child: const SizedBox(width: 10, height: 10), ), ], ), ), ), ); expect( tester.getSemantics(find.byKey(key1)), // Tap action is blocked. matchesSemantics(label: 'label1'), ); expect( tester.getSemantics(find.byKey(key2)), // Tap action is blocked. matchesSemantics(label: 'label2'), ); }); testWidgets('blocking user interaction on a merged child', (WidgetTester tester) async { final key = UniqueKey(); await tester.pumpWidget( MaterialApp( home: Semantics( key: key, container: true, child: Column( children: [ Semantics( blockUserActions: true, label: 'label1', onTap: () {}, child: const SizedBox(width: 10, height: 10), ), Semantics( label: 'label2', onLongPress: () {}, child: const SizedBox(width: 10, height: 10), ), ], ), ), ), ); expect( tester.getSemantics(find.byKey(key)), // Tap action in label1 is blocked, matchesSemantics(label: 'label1\nlabel2', hasLongPressAction: true), ); }); testWidgets('does not merge conflicting actions even if one of them is blocked', ( WidgetTester tester, ) async { final key = UniqueKey(); await tester.pumpWidget( MaterialApp( home: Semantics( key: key, container: true, child: Column( children: [ Semantics( blockUserActions: true, label: 'label1', onTap: () {}, child: const SizedBox(width: 10, height: 10), ), Semantics( label: 'label2', onTap: () {}, child: const SizedBox(width: 10, height: 10), ), ], ), ), ), ); final SemanticsNode node = tester.getSemantics(find.byKey(key)); expect( node, matchesSemantics( children: [ isSemantics(label: 'label1'), isSemantics(label: 'label2'), ], ), ); }); testWidgets('supports heading levels', (WidgetTester tester) async { // Default: not a heading. expect(Semantics(child: const Text('dummy text')).properties.headingLevel, isNull); // Headings level 1-6. for (var level = 1; level <= 6; level++) { final semantics = Semantics(headingLevel: level, child: const Text('dummy text')); expect(semantics.properties.headingLevel, level); } // Invalid heading levels. for (final badLevel in const [-1, 0, 7, 8, 9]) { expect( () => Semantics(headingLevel: badLevel, child: const Text('dummy text')), throwsAssertionError, ); } }); testWidgets('parent heading level takes precedence when it absorbs a child', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); Future pumpHeading(int? level) async { final key = ValueKey('heading-$level'); await tester.pumpWidget( Semantics( key: key, headingLevel: level, child: Text('Heading level $level', textDirection: TextDirection.ltr), ), ); final RenderSemanticsAnnotations object = tester.renderObject( find.byKey(key), ); final config = SemanticsConfiguration(); object.describeSemanticsConfiguration(config); return config; } // Tuples contain (parent level, child level, expected combined level). final scenarios = <(int, int, int)>[ // Case: neither are headings (0, 0, 0), // expect not a heading // Case: parent not a heading, child always wins. (0, 1, 1), (0, 2, 2), // Case: child not a heading, parent always wins. (1, 0, 1), (2, 0, 2), // Case: child heading level higher, parent still wins. (3, 2, 3), (4, 1, 4), // Case: parent heading level higher, parent still wins. (2, 3, 2), (1, 5, 1), ]; for (final scenario in scenarios) { final int parentLevel = scenario.$1; final int childLevel = scenario.$2; final int resultLevel = scenario.$3; final SemanticsConfiguration parent = await pumpHeading( parentLevel == 0 ? null : parentLevel, ); final child = SemanticsConfiguration()..headingLevel = childLevel; parent.absorb(child); expect( reason: 'parent heading level is $parentLevel, ' 'child heading level is $childLevel, ' 'expecting $resultLevel.', parent.headingLevel, resultLevel, ); } semantics.dispose(); }); testWidgets('applies heading semantics to semantics tree', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(title: const Text('Headings')), body: ListView( children: [ for (int level = 1; level <= 6; level++) Semantics( key: ValueKey('heading-$level'), headingLevel: level, child: Text('Heading level $level'), ), const Text('This is not a heading'), ], ), ), ), ); for (var level = 1; level <= 6; level++) { final key = ValueKey('heading-$level'); final SemanticsNode node = tester.getSemantics(find.byKey(key)); expect('$node', contains('headingLevel: $level')); } final SemanticsNode notHeading = tester.getSemantics(find.text('This is not a heading')); expect(notHeading, isNot(contains('headingLevel'))); semantics.dispose(); }); testWidgets('RenderSemanticsAnnotations provides validation result', (WidgetTester tester) async { final semantics = SemanticsTester(tester); Future pumpValidationResult(SemanticsValidationResult result) async { final key = ValueKey('validation-$result'); await tester.pumpWidget( Semantics( key: key, validationResult: result, child: Text('Validation result $result', textDirection: TextDirection.ltr), ), ); final RenderSemanticsAnnotations object = tester.renderObject( find.byKey(key), ); final config = SemanticsConfiguration(); object.describeSemanticsConfiguration(config); return config; } final SemanticsConfiguration noneResult = await pumpValidationResult( SemanticsValidationResult.none, ); expect(noneResult.validationResult, SemanticsValidationResult.none); final SemanticsConfiguration validResult = await pumpValidationResult( SemanticsValidationResult.valid, ); expect(validResult.validationResult, SemanticsValidationResult.valid); final SemanticsConfiguration invalidResult = await pumpValidationResult( SemanticsValidationResult.invalid, ); expect(invalidResult.validationResult, SemanticsValidationResult.invalid); semantics.dispose(); }); testWidgets('validation result precedence', (WidgetTester tester) async { final semantics = SemanticsTester(tester); Future expectValidationResult({ required SemanticsValidationResult outer, required SemanticsValidationResult inner, required SemanticsValidationResult expected, }) async { const key = ValueKey('validated-widget'); await tester.pumpWidget( Semantics( validationResult: outer, child: Semantics( validationResult: inner, child: Text( key: key, 'Outer = $outer; inner = $inner', textDirection: TextDirection.ltr, ), ), ), ); final SemanticsNode result = tester.getSemantics(find.byKey(key)); expect( result, isSemantics(label: 'Outer = $outer; inner = $inner', validationResult: expected), ); } // Outer is none await expectValidationResult( outer: SemanticsValidationResult.none, inner: SemanticsValidationResult.none, expected: SemanticsValidationResult.none, ); await expectValidationResult( outer: SemanticsValidationResult.none, inner: SemanticsValidationResult.valid, expected: SemanticsValidationResult.valid, ); await expectValidationResult( outer: SemanticsValidationResult.none, inner: SemanticsValidationResult.invalid, expected: SemanticsValidationResult.invalid, ); // Outer is valid await expectValidationResult( outer: SemanticsValidationResult.valid, inner: SemanticsValidationResult.none, expected: SemanticsValidationResult.valid, ); await expectValidationResult( outer: SemanticsValidationResult.valid, inner: SemanticsValidationResult.valid, expected: SemanticsValidationResult.valid, ); await expectValidationResult( outer: SemanticsValidationResult.valid, inner: SemanticsValidationResult.invalid, expected: SemanticsValidationResult.invalid, ); // Outer is invalid await expectValidationResult( outer: SemanticsValidationResult.invalid, inner: SemanticsValidationResult.none, expected: SemanticsValidationResult.invalid, ); await expectValidationResult( outer: SemanticsValidationResult.invalid, inner: SemanticsValidationResult.valid, expected: SemanticsValidationResult.invalid, ); await expectValidationResult( outer: SemanticsValidationResult.invalid, inner: SemanticsValidationResult.invalid, expected: SemanticsValidationResult.invalid, ); semantics.dispose(); }); testWidgets('semantics grafting in traversal order', (WidgetTester tester) async { final semantics = SemanticsTester(tester); const identifier = '111'; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Scaffold( body: Column( children: [ Semantics( traversalParentIdentifier: identifier, child: const SizedBox.square(dimension: 10), ), Semantics( traversalChildIdentifier: identifier, child: const SizedBox.square(dimension: 10), ), const SizedBox.square(dimension: 10), ], ), ), ), ); // Semantics tree in traversal order. On web, no grafting. expect( semantics, kIsWeb ? hasSemantics( TestSemantics.root( children: [ TestSemantics(id: 1, traversalParentIdentifier: identifier), TestSemantics(id: 2, traversalChildIdentifier: identifier), ], ), ignoreRect: true, ignoreTransform: true, ) : hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( id: 1, traversalParentIdentifier: identifier, children: [ TestSemantics(id: 2, traversalChildIdentifier: identifier), ], ), ], ), ignoreRect: true, ignoreTransform: true, ), ); // Semantics tree in hit-test order. expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics(id: 1, traversalParentIdentifier: identifier), TestSemantics(id: 2, traversalChildIdentifier: identifier), ], ), ignoreRect: true, ignoreTransform: true, childOrder: DebugSemanticsDumpOrder.inverseHitTest, ), ); semantics.dispose(); }); testWidgets('semantics grafting in traversal order with multiple same traversalChildIdentifier', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); const identifier = '111'; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Scaffold( body: Column( children: [ Semantics( traversalParentIdentifier: identifier, child: const SizedBox.square(dimension: 10), ), Semantics( traversalChildIdentifier: identifier, child: const SizedBox.square(dimension: 10), ), Semantics( traversalChildIdentifier: identifier, child: const SizedBox.square(dimension: 10), ), const SizedBox.square(dimension: 10), ], ), ), ), ); // Semantics tree in traversal order. On web, no grafting. expect( semantics, kIsWeb ? hasSemantics( TestSemantics.root( children: [ TestSemantics(id: 1, traversalParentIdentifier: identifier), TestSemantics(id: 2, traversalChildIdentifier: identifier), TestSemantics(id: 3, traversalChildIdentifier: identifier), ], ), ignoreRect: true, ignoreTransform: true, ) : hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( id: 1, traversalParentIdentifier: identifier, children: [ TestSemantics(id: 2, traversalChildIdentifier: identifier), TestSemantics(id: 3, traversalChildIdentifier: identifier), ], ), ], ), ignoreRect: true, ignoreTransform: true, ), ); // Semantics tree in hit-test order. expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics(id: 1, traversalParentIdentifier: identifier), TestSemantics(id: 2, traversalChildIdentifier: identifier), TestSemantics(id: 3, traversalChildIdentifier: identifier), ], ), ignoreRect: true, ignoreTransform: true, childOrder: DebugSemanticsDumpOrder.inverseHitTest, ), ); semantics.dispose(); }); testWidgets( 'no grafting in traversal order when traversal child is the parent of its traversal parent', (WidgetTester tester) async { final semantics = SemanticsTester(tester); const identifier = '111'; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Scaffold( body: Column( children: [ Semantics( traversalChildIdentifier: identifier, child: TextButton( onPressed: () {}, child: Semantics(traversalParentIdentifier: identifier), ), ), const SizedBox.square(dimension: 10), ], ), ), ), ); final Object? exception = tester.takeException(); expect(exception, isFlutterError); final error = exception! as FlutterError; expect( error.message, 'The traversalParent 2 cannot be the child of the traversalChild 1 in hit-test order', ); semantics.dispose(); }, skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. ); // Regression test for https://github.com/flutter/flutter/issues/173097. testWidgets('Semantics geometry can scroll to reveal hidden children in a scrollable container', ( WidgetTester tester, ) async { final scrollController = ScrollController(); addTearDown(scrollController.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( controller: scrollController, child: Semantics( container: true, explicitChildNodes: true, child: Column( children: [ for (int i = 0; i < 20; i++) ...[ Text('Label text $i'), const SizedBox(height: 50), ], ], ), ), ), ), ); final SemanticsNode label10 = tester.getSemantics( find.text('Label text 10', skipOffstage: false), ); expect(label10, matchesSemantics(isHidden: true)); scrollController.jumpTo(500); await tester.pumpAndSettle(); expect(label10, matchesSemantics(isHidden: false)); // ignore: avoid_redundant_argument_values }); } class CustomSortKey extends OrdinalSortKey { const CustomSortKey(super.order, {super.name}); }