|
| 1 | +// Copyright 2026 The Flutter Authors |
| 2 | +// Use of this source code is governed by a BSD-style license that can be |
| 3 | +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. |
| 4 | + |
| 5 | +import 'package:devtools_app/devtools_app.dart'; |
| 6 | +import 'package:devtools_app_shared/ui.dart'; |
| 7 | +import 'package:devtools_app_shared/utils.dart'; |
| 8 | +import 'package:flutter/material.dart'; |
| 9 | +import 'package:flutter_test/flutter_test.dart'; |
| 10 | + |
| 11 | +/// The smallest contrast ratio WCAG 2.1 accepts for body text at level AA. |
| 12 | +/// |
| 13 | +/// See https://www.w3.org/TR/WCAG21/#contrast-minimum. |
| 14 | +const _minimumContrastRatio = 4.5; |
| 15 | + |
| 16 | +void main() { |
| 17 | + setUp(() { |
| 18 | + setGlobal(IdeTheme, IdeTheme()); |
| 19 | + }); |
| 20 | + |
| 21 | + group('$SidePanelViewer', () { |
| 22 | + // `MarkdownStyleSheet.fromTheme` fills blockquotes with |
| 23 | + // `Colors.blue.shade100` but takes the text color from the theme, so a |
| 24 | + // release note that opens with a blockquote drew `onSurface` text on light |
| 25 | + // blue in the dark theme. |
| 26 | + // Regression test for https://github.com/flutter/devtools/issues/9945. |
| 27 | + for (final useDarkTheme in [true, false]) { |
| 28 | + final themeName = useDarkTheme ? 'dark' : 'light'; |
| 29 | + testWidgets('blockquote text is legible in the $themeName theme', ( |
| 30 | + tester, |
| 31 | + ) async { |
| 32 | + const summary = 'Release notes for Dart and Flutter DevTools.'; |
| 33 | + final controller = SidePanelController(); |
| 34 | + await tester.pumpWidget( |
| 35 | + MaterialApp( |
| 36 | + theme: themeFor( |
| 37 | + isDarkTheme: useDarkTheme, |
| 38 | + ideTheme: IdeTheme(), |
| 39 | + theme: ThemeData( |
| 40 | + useMaterial3: true, |
| 41 | + colorScheme: useDarkTheme ? darkColorScheme : lightColorScheme, |
| 42 | + ), |
| 43 | + ), |
| 44 | + home: SidePanelViewer(controller: controller), |
| 45 | + ), |
| 46 | + ); |
| 47 | + controller.markdown.value = '# Release notes\n\n> $summary'; |
| 48 | + controller.toggleVisibility(true); |
| 49 | + await tester.pumpAndSettle(); |
| 50 | + |
| 51 | + final summaryFinder = find.byWidgetPredicate( |
| 52 | + (widget) => |
| 53 | + widget is RichText && widget.text.toPlainText() == summary, |
| 54 | + ); |
| 55 | + expect(summaryFinder, findsOneWidget); |
| 56 | + |
| 57 | + final textColor = _colorOfSpan( |
| 58 | + tester.widget<RichText>(summaryFinder).text, |
| 59 | + summary, |
| 60 | + ); |
| 61 | + final fillColor = tester |
| 62 | + .widgetList<DecoratedBox>( |
| 63 | + find.ancestor( |
| 64 | + of: summaryFinder, |
| 65 | + matching: find.byType(DecoratedBox), |
| 66 | + ), |
| 67 | + ) |
| 68 | + .map((box) => box.decoration) |
| 69 | + .whereType<BoxDecoration>() |
| 70 | + .map((decoration) => decoration.color) |
| 71 | + .nonNulls |
| 72 | + .first; |
| 73 | + |
| 74 | + expect( |
| 75 | + _contrastRatio(textColor!, fillColor), |
| 76 | + greaterThanOrEqualTo(_minimumContrastRatio), |
| 77 | + ); |
| 78 | + }); |
| 79 | + } |
| 80 | + }); |
| 81 | +} |
| 82 | + |
| 83 | +/// The color the span holding [text] is painted with, or null if [root] holds |
| 84 | +/// no such span. |
| 85 | +Color? _colorOfSpan(InlineSpan root, String text) { |
| 86 | + Color? color; |
| 87 | + root.visitChildren((span) { |
| 88 | + if (span is TextSpan && span.text == text) { |
| 89 | + color = span.style?.color; |
| 90 | + return false; |
| 91 | + } |
| 92 | + return true; |
| 93 | + }); |
| 94 | + return color; |
| 95 | +} |
| 96 | + |
| 97 | +/// The WCAG contrast ratio between [a] and [b], from 1 (identical) to 21 |
| 98 | +/// (black on white). |
| 99 | +double _contrastRatio(Color a, Color b) { |
| 100 | + final luminances = [a.computeLuminance(), b.computeLuminance()]..sort(); |
| 101 | + return (luminances.last + 0.05) / (luminances.first + 0.05); |
| 102 | +} |
0 commit comments