accessibility_test.dart 21.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// 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_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(
25
        Container(
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
          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('black text on white background - Text Widget - inherited style', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
42
        DefaultTextStyle(
43
          style: const TextStyle(fontSize: 14.0, color: Colors.black),
44
          child: Container(
45 46 47 48 49 50 51 52 53 54 55 56
            color: Colors.white,
            child: const Text('this is a test'),
          ),
        ),
      ));
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

    testWidgets('white text on black background - Text Widget - inherited style', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
57
        DefaultTextStyle(
58
          style: const TextStyle(fontSize: 14.0, color: Colors.white),
59
          child: Container(
60 61 62 63 64 65 66 67 68 69 70 71 72
            width: 200.0,
            height: 200.0,
            color: Colors.black,
            child: const Text('this is a test'),
          ),
        ),
      ));
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

    testWidgets('Material text field - amber on amber', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
73 74 75
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: Container(
76 77 78
            width: 200.0,
            height: 200.0,
            color: Colors.amberAccent,
79
            child: TextField(
80
              style: const TextStyle(color: Colors.amber),
81
              controller: TextEditingController(text: 'this is a test'),
82 83 84 85 86 87 88 89 90 91
            ),
          ),
        ),
      ));
      await expectLater(tester, doesNotMeetGuideline(textContrastGuideline));
      handle.dispose();
    });

    testWidgets('Material text field - default style', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
92 93 94
      await tester.pumpWidget(MaterialApp(
          home: Scaffold(
            body: Center(
95 96 97 98 99
              child: SizedBox(
                width: 100,
                child: TextField(
                  controller: TextEditingController(text: 'this is a test'),
                ),
100 101 102 103 104
              ),
            ),
          ),
        ),
      );
105
      await tester.idle();
106 107 108 109
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

110
    testWidgets('yellow text on yellow background fails with correct message', (WidgetTester tester) async {
111 112
      final SemanticsHandle handle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
113
        Container(
114 115 116 117 118 119 120 121 122 123 124 125
          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,
126
        'SemanticsNode#4(Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), label: "this is a test",'
127
        ' textDirection: ltr):\nExpected contrast ratio of at least '
128
        '4.5 but found 1.17 for a font size of 14.0. The '
129
        'computed light color was: Color(0xfffafafa), The computed dark color was:'
130
        ' Color(0xffffeb3b)\n'
131 132 133
        'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html');
      handle.dispose();
    });
134 135 136 137

    testWidgets('label without corresponding text is skipped', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
138
        Semantics(
139 140
          label: 'This is not text',
          container: true,
141
          child: Container(
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
            width: 200.0,
            height: 200.0,
            child: const 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(
157
        Stack(
158
          children: <Widget>[
159
            Positioned(
160
              left: -300.0,
161
              child: Container(
162 163 164 165 166 167 168 169
                width: 200.0,
                height: 200.0,
                color: Colors.yellow,
                child: const Text(
                  'this is a test',
                  style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent),
                ),
              ),
170
            ),
171 172 173 174 175 176 177 178
          ],
        )
      ));

      final Evaluation result = await textContrastGuideline.evaluate(tester);
      expect(result.passed, true);
      handle.dispose();
    });
179 180
  });

181
  group('custom minimum contrast guideline', () {
182
    Widget _icon({IconData icon = Icons.search, required Color color, required Color background}) {
183 184 185 186 187 188 189
      return Container(
        padding: const EdgeInsets.all(8.0),
        color: background,
        child: Icon(icon, color: color),
      );
    }

190
    Widget _text({String text = 'Text', required Color color, required Color background}) {
191 192 193 194 195 196 197
      return Container(
        padding: const EdgeInsets.all(8.0),
        color: background,
        child: Text(text, style: TextStyle(color: color)),
      );
    }

198
    Widget _row(List<Widget> widgets) => _boilerplate(Row(children: widgets));
199 200 201 202 203 204 205

    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>[
206 207
        _icon(color: Colors.black, background: Colors.white),
        _icon(color: Colors.black, background: Colors.white),
208 209 210 211 212 213 214
      ]));

      await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('Black icons on black background', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
215 216
        _icon(color: Colors.black, background: Colors.black),
        _icon(color: Colors.black, background: Colors.black),
217 218 219 220 221 222 223
      ]));

      await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('White icons on black background ("dark mode")', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
224 225
        _icon(color: Colors.white, background: Colors.black),
        _icon(color: Colors.white, background: Colors.black),
226 227 228 229 230 231 232
      ]));

      await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('Using different icons', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
233 234 235 236
        _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),
237 238 239 240 241 242 243
      ]));

      await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('One invalid instance fails entire test', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
244 245
        _icon(color: Colors.black, background: Colors.white),
        _icon(color: Colors.black, background: Colors.black),
246 247 248 249 250 251 252
      ]));

      await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('White on different colors, passing', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
253 254 255 256
        _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),
257 258 259 260 261 262 263
      ]));

      await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('White on different colors, failing', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
264 265 266 267
        _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),
268 269 270 271 272 273 274 275 276 277 278 279 280
      ]));

      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>[
281 282
        _text(color: Colors.black, background: Colors.white),
        _text(color: Colors.black, background: Colors.black),
283 284 285 286 287 288 289
      ]));

      await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
    });

    testWidgets('Guideline ignores widgets of other types', (WidgetTester tester) async {
      await tester.pumpWidget(_row(<Widget>[
290 291 292 293
        _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),
294 295 296 297 298 299 300 301 302
      ]));

      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>[
303 304
        _icon(color: Colors.blue, background: Colors.white),
        _icon(color: Colors.black, background: Colors.white),
305 306 307 308 309 310 311 312
      ]));

      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>[
313 314
        _text(color: Colors.blue, background: Colors.white),
        _text(color: Colors.black, background: Colors.white),
315 316 317 318 319 320 321 322
      ]));

      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>[
323 324 325 326
        _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),
327 328 329 330 331 332 333 334
      ]));

      await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
      await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findTexts, minimumRatio: 3.0)));
    });

  });

335 336 337 338
  group('tap target size guideline', () {
    testWidgets('Tappable box at 48 by 48', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
339
        SizedBox(
340 341
          width: 48.0,
          height: 48.0,
342
          child: GestureDetector(
343
            onTap: () { },
344 345 346 347 348 349 350 351 352 353
          ),
        ),
      ));
      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(
354
        SizedBox(
355 356
          width: 47.0,
          height: 48.0,
357
          child: GestureDetector(
358
            onTap: () { },
359 360 361 362 363 364 365 366 367 368
          ),
        ),
      ));
      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(
369
        SizedBox(
370 371
          width: 48.0,
          height: 47.0,
372
          child: GestureDetector(
373
            onTap: () { },
374 375 376 377 378 379 380 381 382 383
          ),
        ),
      ));
      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(
384
        Transform.scale(
385
          scale: 0.5, // should have new height of 24 by 24.
386
          child: SizedBox(
387 388
            width: 48.0,
            height: 48.0,
389
            child: GestureDetector(
390
              onTap: () { },
391 392 393 394 395 396 397 398 399 400 401
            ),
          ),
        ),
      ));
      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(
402
        SizedBox(
403 404
          width: 48.0,
          height: 47.0,
405
          child: GestureDetector(
406
            onTap: () { },
407 408 409 410 411 412
          ),
        ),
      ));
      final Evaluation result = await androidTapTargetGuideline.evaluate(tester);
      expect(result.passed, false);
      expect(result.reason,
413
        'SemanticsNode#4(Rect.fromLTRB(376.0, 276.5, 424.0, 323.5), actions: [tap]): expected tap '
414 415 416 417
        '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();
    });
418 419 420

    testWidgets('Box that overlaps edge of window is skipped', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
421
      final Widget smallBox = SizedBox(
422 423
        width: 48.0,
        height: 47.0,
424
        child: GestureDetector(
425
          onTap: () { },
426 427 428
        ),
      );
      await tester.pumpWidget(
429 430
        MaterialApp(
          home: Stack(
431
            children: <Widget>[
432
              Positioned(
433 434 435 436 437 438 439 440 441 442 443 444 445
                left: 0.0,
                top: -1.0,
                child: smallBox,
              ),
            ],
          ),
        ),
      );

      final Evaluation overlappingTopResult = await androidTapTargetGuideline.evaluate(tester);
      expect(overlappingTopResult.passed, true);

      await tester.pumpWidget(
446 447
        MaterialApp(
          home: Stack(
448
            children: <Widget>[
449
              Positioned(
450 451 452 453 454 455 456 457 458 459 460 461 462
                left: -1.0,
                top: 0.0,
                child: smallBox,
              ),
            ],
          ),
        ),
      );

      final Evaluation overlappingLeftResult = await androidTapTargetGuideline.evaluate(tester);
      expect(overlappingLeftResult.passed, true);

      await tester.pumpWidget(
463 464
        MaterialApp(
          home: Stack(
465
            children: <Widget>[
466
              Positioned(
467 468 469 470 471 472 473 474 475 476 477 478
                bottom: -1.0,
                child: smallBox,
              ),
            ],
          ),
        ),
      );

      final Evaluation overlappingBottomResult = await androidTapTargetGuideline.evaluate(tester);
      expect(overlappingBottomResult.passed, true);

      await tester.pumpWidget(
479 480
        MaterialApp(
          home: Stack(
481
            children: <Widget>[
482
              Positioned(
483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498
                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(
499 500
        MergeSemantics(
          child: Semantics(
501
            container: true,
502
            child: SizedBox(
503 504
              width: 50.0,
              height: 50.0,
505
              child: Semantics(
506
                container: true,
507
                child: GestureDetector(
508
                  onTap: () { },
509
                  child: const SizedBox(width: 4.0, height: 4.0),
510 511
                ),
              ),
512
            ),
513
          ),
514 515 516 517 518 519 520
        )
      ));

      final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester);
      expect(overlappingRightResult.passed, true);
      handle.dispose();
    });
521
  });
522

523
  group('Labeled tappable node guideline', () {
524 525 526 527 528
    testWidgets('Passes when node is labeled', (WidgetTester tester) async {
      final SemanticsHandle handle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(Semantics(
        container: true,
        child: const SizedBox(width: 10.0, height: 10.0),
529
        onTap: () { },
530 531 532 533 534 535 536 537 538 539 540
        label: 'test',
      )));
      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,
        child: const SizedBox(width: 10.0, height: 10.0),
541
        onLongPress: () { },
542 543 544 545 546 547 548 549 550 551 552 553
        label: '',
      )));
      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,
        child: const SizedBox(width: 10.0, height: 10.0),
554
        onTap: () { },
555 556 557 558 559 560 561 562 563 564 565
        label: '',
      )));
      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,
566
        onLongPress: () { },
567 568 569 570 571 572 573 574 575 576 577 578
        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();
    });
  });

579 580 581 582 583 584 585
  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,
586 587 588 589 590
          body: ElevatedButton(
            style: ElevatedButton.styleFrom(
              primary: const Color(0xFFFBBC04),
              elevation: 0,
            ),
591 592 593 594 595 596 597 598
            onPressed: () {},
            child: const Text('Button', style: TextStyle(color: Colors.black)),
        ),
      ),
    ));
    await expectLater(tester, meetsGuideline(textContrastGuideline));
    handle.dispose();
  });
599 600 601
}

Widget _boilerplate(Widget child) {
602 603
  return MaterialApp(
    home: Scaffold(body: Center(child: child)),
604 605
  );
}