// 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/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('text contrast guideline', () { testWidgets('black text on white background - Text Widget - direct style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.black), ), ), ); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); testWidgets('white text on black background - Text Widget - direct style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( Container( width: 200.0, height: 200.0, color: Colors.black, child: const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.white), ), ), ), ); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); const Color surface = Color(0xFFF0F0F0); /// Shades of blue with contrast ratio of 2.9, 4.4, 4.5 from [surface]. const Color blue29 = Color(0xFF7E7EFB); const Color blue44 = Color(0xFF5757FF); const Color blue45 = Color(0xFF5252FF); const List<TextStyle> textStylesMeetingGuideline = <TextStyle>[ TextStyle(color: blue44, backgroundColor: surface, fontSize: 18), TextStyle(color: blue44, backgroundColor: surface, fontSize: 14, fontWeight: FontWeight.bold), TextStyle(color: blue45, backgroundColor: surface), ]; const List<TextStyle> textStylesDoesNotMeetingGuideline = <TextStyle>[ TextStyle(color: blue44, backgroundColor: surface), TextStyle(color: blue29, backgroundColor: surface, fontSize: 18), ]; Widget _appWithTextWidget(TextStyle style) => _boilerplate( Text('this is text', style: style.copyWith(height: 30.0)), ); for (final TextStyle style in textStylesMeetingGuideline) { testWidgets('text with style $style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_appWithTextWidget(style)); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); } for (final TextStyle style in textStylesDoesNotMeetingGuideline) { testWidgets('text with $style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_appWithTextWidget(style)); await expectLater(tester, doesNotMeetGuideline(textContrastGuideline)); handle.dispose(); }); } testWidgets('black text on white background - Text Widget - direct style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.black), ), ), ); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); testWidgets('white text on black background - Text Widget - direct style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( Container( width: 200.0, height: 200.0, color: Colors.black, child: const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.white), ), ), ), ); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); testWidgets('Material text field - amber on amber', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( Container( width: 200.0, height: 200.0, color: Colors.amberAccent, child: TextField( style: const TextStyle(color: Colors.amber), controller: TextEditingController(text: 'this is a test'), ), ), ), ); await expectLater(tester, doesNotMeetGuideline(textContrastGuideline)); handle.dispose(); }); testWidgets('Material text field - default style', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( SizedBox( width: 100, child: TextField( controller: TextEditingController(text: 'this is a test'), ), ), ), ); await tester.idle(); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); testWidgets('yellow text on yellow background fails with correct message', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( Container( width: 200.0, height: 200.0, color: Colors.yellow, child: const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent), ), ), ), ); final Evaluation result = await textContrastGuideline.evaluate(tester); expect(result.passed, false); expect( result.reason, 'SemanticsNode#4(Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), ' 'label: "this is a test", textDirection: ltr):\n' 'Expected contrast ratio of at least 4.5 but found 1.17 for a font ' 'size of 14.0.\n' 'The computed colors was:\n' 'light - Color(0xfffafafa), dark - Color(0xffffeb3b)\n' 'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html', ); handle.dispose(); }); testWidgets('label without corresponding text is skipped', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( Semantics( label: 'This is not text', container: true, child: const SizedBox( width: 200.0, height: 200.0, child: Placeholder(), ), ), ), ); final Evaluation result = await textContrastGuideline.evaluate(tester); expect(result.passed, true); handle.dispose(); }); testWidgets('offscreen text is skipped', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( Stack( children: <Widget>[ Positioned( left: -300.0, child: Container( width: 200.0, height: 200.0, color: Colors.yellow, child: const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent), ), ), ), ], ), ), ); final Evaluation result = await textContrastGuideline.evaluate(tester); expect(result.passed, true); handle.dispose(); }); testWidgets('Disabled button is excluded from text contrast guideline', (WidgetTester tester) async { // Regression test https://github.com/flutter/flutter/issues/94428 final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( _boilerplate( ElevatedButton( onPressed: null, child: Container( width: 200.0, height: 200.0, color: Colors.yellow, child: const Text( 'this is a test', style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent), ), ), ), ), ); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); }); group('custom minimum contrast guideline', () { Widget _icon({ IconData icon = Icons.search, required Color color, required Color background, }) { return Container( padding: const EdgeInsets.all(8.0), color: background, child: Icon(icon, color: color), ); } Widget _text({ String text = 'Text', required Color color, required Color background, }) { return Container( padding: const EdgeInsets.all(8.0), color: background, child: Text(text, style: TextStyle(color: color)), ); } Widget _row(List<Widget> widgets) => _boilerplate(Row(children: widgets)); final Finder findIcons = find.byWidgetPredicate((Widget widget) => widget is Icon); final Finder findTexts = find.byWidgetPredicate((Widget widget) => widget is Text); final Finder findIconsAndTexts = find.byWidgetPredicate((Widget widget) => widget is Icon || widget is Text); testWidgets('Black icons on white background', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white), ])); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('Black icons on black background', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.black, background: Colors.black), _icon(color: Colors.black, background: Colors.black), ])); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('White icons on black background ("dark mode")', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.white, background: Colors.black), _icon(color: Colors.white, background: Colors.black), ])); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('Using different icons', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.black, background: Colors.white, icon: Icons.more_horiz), _icon(color: Colors.black, background: Colors.white, icon: Icons.description), _icon(color: Colors.black, background: Colors.white, icon: Icons.image), _icon(color: Colors.black, background: Colors.white, icon: Icons.beach_access), ])); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('One invalid instance fails entire test', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.black), ])); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('White on different colors, passing', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.white, background: Colors.red[800]!, icon: Icons.more_horiz), _icon(color: Colors.white, background: Colors.green[800]!, icon: Icons.description), _icon(color: Colors.white, background: Colors.blue[800]!, icon: Icons.image), _icon(color: Colors.white, background: Colors.purple[800]!, icon: Icons.beach_access), ])); await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); }); testWidgets('White on different colors, failing', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.white, background: Colors.red[200]!, icon: Icons.more_horiz), _icon(color: Colors.white, background: Colors.green[400]!, icon: Icons.description), _icon(color: Colors.white, background: Colors.blue[600]!, icon: Icons.image), _icon(color: Colors.white, background: Colors.purple[800]!, icon: Icons.beach_access), ])); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('Absence of icons, passing', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[])); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('Absence of icons, passing - 2nd test', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _text(color: Colors.black, background: Colors.white), _text(color: Colors.black, background: Colors.black), ])); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); }); testWidgets('Guideline ignores widgets of other types', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white), _text(color: Colors.black, background: Colors.white), _text(color: Colors.black, background: Colors.black), ])); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findTexts)), ); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIconsAndTexts)), ); }); testWidgets('Custom minimum ratio - Icons', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.blue, background: Colors.white), _icon(color: Colors.black, background: Colors.white), ])); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons, minimumRatio: 3.0)), ); }); testWidgets('Custom minimum ratio - Texts', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _text(color: Colors.blue, background: Colors.white), _text(color: Colors.black, background: Colors.white), ])); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findTexts)), ); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findTexts, minimumRatio: 3.0)), ); }); testWidgets( 'Custom minimum ratio - Different standards for icons and texts', (WidgetTester tester) async { await tester.pumpWidget(_row(<Widget>[ _icon(color: Colors.blue, background: Colors.white), _icon(color: Colors.black, background: Colors.white), _text(color: Colors.blue, background: Colors.white), _text(color: Colors.black, background: Colors.white), ])); await expectLater( tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)), ); await expectLater( tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findTexts, minimumRatio: 3.0)), ); }); }); group('tap target size guideline', () { testWidgets('Tappable box at 48 by 48', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate( SizedBox( width: 48.0, height: 48.0, child: GestureDetector(onTap: () {}), ), )); await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); handle.dispose(); }); testWidgets('Tappable box at 47 by 48', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate( SizedBox( width: 47.0, height: 48.0, child: GestureDetector(onTap: () {}), ), )); await expectLater( tester, doesNotMeetGuideline(androidTapTargetGuideline), ); handle.dispose(); }); testWidgets('Tappable box at 48 by 47', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate( SizedBox( width: 48.0, height: 47.0, child: GestureDetector(onTap: () {}), ), )); await expectLater( tester, doesNotMeetGuideline(androidTapTargetGuideline), ); handle.dispose(); }); testWidgets('Tappable box at 48 by 48 shrunk by transform', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate( Transform.scale( scale: 0.5, // should have new height of 24 by 24. child: SizedBox( width: 48.0, height: 48.0, child: GestureDetector(onTap: () {}), ), ), )); await expectLater( tester, doesNotMeetGuideline(androidTapTargetGuideline), ); handle.dispose(); }); testWidgets('Too small tap target fails with the correct message', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate( SizedBox( width: 48.0, height: 47.0, child: GestureDetector(onTap: () {}), ), )); final Evaluation result = await androidTapTargetGuideline.evaluate(tester); expect(result.passed, false); expect( result.reason, 'SemanticsNode#4(Rect.fromLTRB(376.0, 276.5, 424.0, 323.5), ' 'actions: [tap]): expected tap ' 'target size of at least Size(48.0, 48.0), ' 'but found Size(48.0, 47.0)\n' 'See also: https://support.google.com/accessibility/android/answer/7101858?hl=en', ); handle.dispose(); }); testWidgets('Box that overlaps edge of window is skipped', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); final Widget smallBox = SizedBox( width: 48.0, height: 47.0, child: GestureDetector(onTap: () {}), ); await tester.pumpWidget( MaterialApp( home: Stack( children: <Widget>[ Positioned( left: 0.0, top: -1.0, child: smallBox, ), ], ), ), ); final Evaluation overlappingTopResult = await androidTapTargetGuideline.evaluate(tester); expect(overlappingTopResult.passed, true); await tester.pumpWidget( MaterialApp( home: Stack( children: <Widget>[ Positioned( left: -1.0, top: 0.0, child: smallBox, ), ], ), ), ); final Evaluation overlappingLeftResult = await androidTapTargetGuideline.evaluate(tester); expect(overlappingLeftResult.passed, true); await tester.pumpWidget( MaterialApp( home: Stack( children: <Widget>[ Positioned( bottom: -1.0, child: smallBox, ), ], ), ), ); final Evaluation overlappingBottomResult = await androidTapTargetGuideline.evaluate(tester); expect(overlappingBottomResult.passed, true); await tester.pumpWidget( MaterialApp( home: Stack( children: <Widget>[ Positioned( right: -1.0, child: smallBox, ), ], ), ), ); final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester); expect(overlappingRightResult.passed, true); handle.dispose(); }); testWidgets('Does not fail on mergedIntoParent child', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate(MergeSemantics( child: Semantics( container: true, child: SizedBox( width: 50.0, height: 50.0, child: Semantics( container: true, child: GestureDetector( onTap: () {}, child: const SizedBox(width: 4.0, height: 4.0), ), ), ), ), ))); final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester); expect(overlappingRightResult.passed, true); handle.dispose(); }); testWidgets('Does not fail on links', (WidgetTester tester) async { Widget textWithLink() { return Builder( builder: (BuildContext context) { return RichText( text: TextSpan( children: <InlineSpan>[ const TextSpan(text: 'See examples at '), TextSpan( text: 'flutter repo', recognizer: TapGestureRecognizer()..onTap = () {}, ), ], ), ); }, ); } final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate(textWithLink())); await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); handle.dispose(); }); }); group('Labeled tappable node guideline', () { testWidgets('Passes when node is labeled', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate(Semantics( container: true, onTap: () {}, label: 'test', child: const SizedBox(width: 10.0, height: 10.0), ))); final Evaluation result = await labeledTapTargetGuideline.evaluate(tester); expect(result.passed, true); handle.dispose(); }); testWidgets('Fails if long-press has no label', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate(Semantics( container: true, onLongPress: () {}, label: '', child: const SizedBox(width: 10.0, height: 10.0), ))); final Evaluation result = await labeledTapTargetGuideline.evaluate(tester); expect(result.passed, false); handle.dispose(); }); testWidgets('Fails if tap has no label', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate(Semantics( container: true, onTap: () {}, label: '', child: const SizedBox(width: 10.0, height: 10.0), ))); final Evaluation result = await labeledTapTargetGuideline.evaluate(tester); expect(result.passed, false); handle.dispose(); }); testWidgets('Passes if tap is merged into labeled node', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(_boilerplate(Semantics( container: true, onLongPress: () {}, label: '', child: Semantics( label: 'test', child: const SizedBox(width: 10.0, height: 10.0), ), ))); final Evaluation result = await labeledTapTargetGuideline.evaluate(tester); expect(result.passed, true); handle.dispose(); }); }); testWidgets('regression test for material widget', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(MaterialApp( theme: ThemeData.light(), home: Scaffold( backgroundColor: Colors.white, body: ElevatedButton( style: ElevatedButton.styleFrom( primary: const Color(0xFFFBBC04), elevation: 0, ), onPressed: () {}, child: const Text('Button', style: TextStyle(color: Colors.black)), ), ), )); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); } Widget _boilerplate(Widget child) => MaterialApp(home: Scaffold(body: Center(child: child)));