// 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 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('Radio group control test', (WidgetTester tester) async { final key0 = UniqueKey(); final key1 = UniqueKey(); await tester.pumpWidget( Material( child: TestRadioGroup( child: Column( children: [ Radio(key: key0, value: 0), Radio(key: key1, value: 1), ], ), ), ), ); expect( tester.getSemantics(find.byKey(key0)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), ); expect( tester.getSemantics(find.byKey(key1)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), ); await tester.tap(find.byKey(key0)); await tester.pumpAndSettle(); expect( tester.getSemantics(find.byKey(key0)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: true, isEnabled: true), ); expect( tester.getSemantics(find.byKey(key1)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), ); await tester.tap(find.byKey(key1)); await tester.pumpAndSettle(); expect( tester.getSemantics(find.byKey(key0)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), ); expect( tester.getSemantics(find.byKey(key1)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: true, isEnabled: true), ); }); testWidgets('Radio group can have disabled radio', (WidgetTester tester) async { final key0 = UniqueKey(); final key1 = UniqueKey(); await tester.pumpWidget( Material( child: TestRadioGroup( child: Column( children: [ Radio(key: key0, value: 0, enabled: false), Radio(key: key1, value: 1), ], ), ), ), ); expect( tester.getSemantics(find.byKey(key0)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: false), ); expect( tester.getSemantics(find.byKey(key1)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), ); await tester.tap(find.byKey(key0)); await tester.pumpAndSettle(); // Can't be select because the radio is disabled. expect( tester.getSemantics(find.byKey(key0)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: false), ); expect( tester.getSemantics(find.byKey(key1)), isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), ); }); testWidgets('Radio group will not merge up', (WidgetTester tester) async { await tester.pumpWidget( Material( child: Semantics( container: true, child: Column( children: [ Checkbox(value: true, onChanged: (bool? value) {}), const TestRadioGroup( child: Column(children: [Radio(value: 0), Radio(value: 1)]), ), Checkbox(value: true, onChanged: (bool? value) {}), ], ), ), ), ); final SemanticsNode radioGroup = tester.getSemantics(find.byType(RadioGroup)); expect(radioGroup.childrenCount, 2); }); testWidgets('Radio group can use arrow key', (WidgetTester tester) async { final key0 = UniqueKey(); final key1 = UniqueKey(); final key2 = UniqueKey(); final focusNode = FocusNode(); addTearDown(focusNode.dispose); await tester.pumpWidget( MaterialApp( home: Material( child: TestRadioGroup( child: Column( children: [ Radio(key: key0, focusNode: focusNode, value: 0), Radio(key: key1, value: 1), Radio(key: key2, value: 2), ], ), ), ), ), ); final TestRadioGroupState state = tester.state>( find.byType(TestRadioGroup), ); await tester.tap(find.byKey(key0)); focusNode.requestFocus(); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(focusNode.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pumpAndSettle(); expect(state.groupValue, 1); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); expect(state.groupValue, 2); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); // Wrap around expect(state.groupValue, 0); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); // Wrap around expect(state.groupValue, 2); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); // Wrap around expect(state.groupValue, 1); }); testWidgets('Radio group arrow key skips disabled radio', (WidgetTester tester) async { final key0 = UniqueKey(); final key1 = UniqueKey(); final key2 = UniqueKey(); final focusNode = FocusNode(); addTearDown(focusNode.dispose); await tester.pumpWidget( MaterialApp( home: Material( child: TestRadioGroup( child: Column( children: [ Radio(key: key0, focusNode: focusNode, value: 0), Radio(key: key1, enabled: false, value: 1), Radio(key: key2, value: 2), ], ), ), ), ), ); final TestRadioGroupState state = tester.state>( find.byType(TestRadioGroup), ); await tester.tap(find.byKey(key0)); focusNode.requestFocus(); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(focusNode.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pumpAndSettle(); expect(state.groupValue, 2); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); // Wrap around expect(state.groupValue, 0); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); expect(state.groupValue, 2); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); expect(state.groupValue, 0); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); // Wrap around expect(state.groupValue, 2); }); testWidgets('Radio group can tab in and out', (WidgetTester tester) async { final key0 = UniqueKey(); final key1 = UniqueKey(); final key2 = UniqueKey(); final radio0 = FocusNode(); addTearDown(radio0.dispose); final radio1 = FocusNode(); addTearDown(radio1.dispose); final textFieldBefore = FocusNode(); addTearDown(textFieldBefore.dispose); final textFieldAfter = FocusNode(); addTearDown(textFieldAfter.dispose); await tester.pumpWidget( MaterialApp( home: Material( child: Column( children: [ TextField(focusNode: textFieldBefore), TestRadioGroup( child: Column( children: [ Radio(key: key0, focusNode: radio0, value: 0), Radio(key: key1, focusNode: radio1, value: 1), Radio(key: key2, value: 2), ], ), ), TextField(focusNode: textFieldAfter), ], ), ), ), ); textFieldBefore.requestFocus(); await tester.pump(); expect(textFieldBefore.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); // If no selected radio, focus the first. expect(textFieldBefore.hasFocus, isFalse); expect(radio0.hasFocus, isTrue); // tab out the radio group. await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(radio0.hasFocus, isFalse); expect(radio1.hasFocus, isFalse); expect(textFieldAfter.hasFocus, isTrue); // Select the radio 1 await tester.tap(find.byKey(key1)); await tester.pump(); final TestRadioGroupState state = tester.state>( find.byType(TestRadioGroup), ); expect(state.groupValue, 1); // focus textFieldAfter again. textFieldAfter.requestFocus(); await tester.pump(); expect(textFieldAfter.hasFocus, isTrue); // shift+tab in the radio again. await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); await tester.pump(); // Should focus selected radio expect(radio0.hasFocus, isFalse); expect(radio1.hasFocus, isTrue); expect(textFieldAfter.hasFocus, isFalse); }); // Regression test for https://github.com/flutter/flutter/issues/175258. testWidgets('Radio group throws on multiple selection', (WidgetTester tester) async { final key1 = UniqueKey(); await tester.pumpWidget( MaterialApp( home: Material( child: TestRadioGroup( child: Column( children: [ const Radio(value: 0), Radio(key: key1, value: 1), const Radio(value: 1), const Radio(value: 2), ], ), ), ), ), ); expect(tester.takeException(), isNull); await tester.tap(find.byKey(key1)); await tester.pump(); expect( tester.takeException(), isA().having( (FlutterError e) => e.message, 'message', "RadioGroupPolicy can't be used for a radio group that allows multiple selection.", ), ); }); // Regression test for https://github.com/flutter/flutter/issues/175258. testWidgets('Radio group does not throw when number of children decreases', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: Material( child: RadioGroup( onChanged: (_) {}, groupValue: 4, child: const Column( children: [ Radio(value: 0), Radio(value: 1), Radio(value: 2), Radio(value: 3), Radio(value: 4), ], ), ), ), ), ); expect(tester.takeException(), isNull); await tester.pumpWidget( MaterialApp( home: Material( child: RadioGroup( onChanged: (_) {}, groupValue: 4, child: const Column( children: [ Radio(value: 1), Radio(value: 2), Radio(value: 3), Radio(value: 4), ], ), ), ), ), ); expect(tester.takeException(), isNull); }); // Regression test for https://github.com/flutter/flutter/issues/175511. testWidgets('Radio group does not intercept key events when no radio is focused', ( WidgetTester tester, ) async { final log = []; late final shortcuts = { const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(() => log.add('←')), const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(() => log.add('→')), const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(() => log.add('↓')), const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(() => log.add('↑')), const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(() => log.add('_')), }; final firstRadioFocusNode = FocusNode(); addTearDown(firstRadioFocusNode.dispose); final textFieldFocusNode = FocusNode(); addTearDown(textFieldFocusNode.dispose); await tester.pumpWidget( MaterialApp( home: Material( child: Shortcuts( shortcuts: shortcuts, child: TestRadioGroup( child: Column( children: [ Radio(focusNode: firstRadioFocusNode, value: 0), const RadioListTile(value: 1), const Radio(value: 2), TextField(focusNode: textFieldFocusNode), ], ), ), ), ), ), ); final TestRadioGroupState state = tester.state>( find.byType(TestRadioGroup), ); // Focus on the first radio and toggle it. firstRadioFocusNode.requestFocus(); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(firstRadioFocusNode.hasFocus, isTrue); // Toggle the second radio with shortcut. await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); expect(state.groupValue, 1); // Log is empty because radio group handles shortcuts. expect(log, isEmpty); // Toggle the first radio with shortcut. await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, isEmpty); // Move focus to the text field. // Now radio group will ignore shortcuts as there are no focused radios. textFieldFocusNode.requestFocus(); await tester.pumpAndSettle(); // Verify that shortcuts are not intercepted by the radio group. await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, ['←']); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, ['←', '→']); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, ['←', '→', '↓']); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, ['←', '→', '↓', '↑']); await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, ['←', '→', '↓', '↑', '_']); log.clear(); expect(log, isEmpty); // Focus on the first radio. firstRadioFocusNode.requestFocus(); await tester.pump(); expect(state.groupValue, 0); expect(firstRadioFocusNode.hasFocus, isTrue); // Verify that radio group handles shortcuts again. await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pumpAndSettle(); expect(state.groupValue, 1); expect(log, isEmpty); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); expect(state.groupValue, 0); expect(log, isEmpty); }); } class TestRadioGroup extends StatefulWidget { const TestRadioGroup({super.key, required this.child}); final Widget child; @override State createState() => TestRadioGroupState(); } class TestRadioGroupState extends State> { T? groupValue; @override Widget build(BuildContext context) { return RadioGroup( onChanged: (T? newValue) { setState(() { groupValue = newValue; }); }, groupValue: groupValue, child: widget.child, ); } }