import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ar_autopilot_display/theme/theme_registry.dart'; import 'package:ar_autopilot_display/theme/autopilot_theme.dart'; // ── WCAG 2.1 helpers ────────────────────────────────────────────────────────── /// WCAG 2.1 relative luminance of a colour. /// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance double _luminance(Color c) { double lin(int channel) { final s = channel / 255.0; return s <= 0.04045 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4).toDouble(); } return 0.2126 * lin(c.red) + 0.7152 * lin(c.green) + 0.0722 * lin(c.blue); } /// WCAG 2.1 contrast ratio (symmetric). double _contrast(Color a, Color b) { final la = _luminance(a); final lb = _luminance(b); final lighter = max(la, lb); final darker = min(la, lb); return (lighter + 0.05) / (darker + 0.05); } /// Returns all colour stops from a [Gradient]. List _gradientColors(Gradient g) { if (g is LinearGradient) return g.colors; if (g is RadialGradient) return g.colors; return []; } /// Returns the **maximum** contrast of [text] against any stop in [bg]. /// /// We test the best-case stop: the gradient stop that gives the highest /// contrast against the text colour. This ensures the design intent is /// achievable — the text will visually overlap the highest-contrast region. /// /// For DISENGAGE (≥ 7:1 AAA): tests that at least one region of the gradient /// button delivers the required accessibility threshold. double _maxContrastVsGradient(Color text, Gradient bg) { final stops = _gradientColors(bg); if (stops.isEmpty) return 0; return stops.map((c) => _contrast(text, c)).reduce(max); } // ── Tests ───────────────────────────────────────────────────────────────────── void main() { group('WCAG contrast — mandatory Sprint 4 gates', () { for (final theme in ThemeRegistry.all) { _contrastTests(theme); } }); } void _contrastTests(AutopilotTheme t) { group('Theme: ${t.id}', () { // ── DISENGAGE ───────────────────────────────────────────────────────────── test('DISENGAGE text vs button background ≥ 7:1 (WCAG AAA)', () { final ratio = _maxContrastVsGradient(t.disengageText, t.disengageBackground); expect( ratio, greaterThanOrEqualTo(7.0), reason: '${t.id}: DISENGAGE text contrast is ${ratio.toStringAsFixed(2)}:1 — ' 'must be ≥ 7:1 (AAA). Adjust disengageText or disengageBackground.', ); }); // ── Action buttons ──────────────────────────────────────────────────────── test('Action button text vs button background ≥ 4.5:1 (WCAG AA)', () { final ratio = _maxContrastVsGradient( t.actionButtonText, t.actionButtonBackground, ); expect( ratio, greaterThanOrEqualTo(4.5), reason: '${t.id}: action button text contrast is ${ratio.toStringAsFixed(2)}:1 — ' 'must be ≥ 4.5:1 (AA). Operators must read DODGE/±1°/±10° without hover.', ); }); // ── Body text ───────────────────────────────────────────────────────────── test('textMain vs background ≥ 4.5:1 (WCAG AA)', () { final ratio = _contrast(t.textMain, t.background); expect( ratio, greaterThanOrEqualTo(4.5), reason: '${t.id}: textMain contrast is ${ratio.toStringAsFixed(2)}:1 — ' 'must be ≥ 4.5:1 (AA).', ); }); // ── Set-point legibility ────────────────────────────────────────────────── test('setLight vs background ≥ 3:1 (large UI element)', () { final ratio = _contrast(t.setLight, t.background); expect( ratio, greaterThanOrEqualTo(3.0), reason: '${t.id}: setLight contrast is ${ratio.toStringAsFixed(2)}:1 — ' 'must be ≥ 3:1 (set-point is a large graphical element).', ); }); // ── OK / Warn semantic colours ──────────────────────────────────────────── test('okColor vs background ≥ 3:1 (status indicator)', () { final ratio = _contrast(t.okColor, t.background); expect( ratio, greaterThanOrEqualTo(3.0), reason: '${t.id}: okColor contrast is ${ratio.toStringAsFixed(2)}:1 — ' 'status indicators require ≥ 3:1.', ); }); }); }