Files
AR-Autopilot/display/test/theme/theme_registry_test.dart
alro65 cb138a3248 sprint-4b: Flutter display theme system (AutopilotTheme + 4 palettes + tests)
Implements the visual theme system specified in mock up software.pdf.

Flutter project — display/:
- pubspec.yaml: ar_autopilot_display v0.4.0+4 (provider + shared_preferences)
- lib/theme/autopilot_theme.dart: AutopilotTheme class with 30+ design tokens
  (backgrounds, panels, text, accent, set-point, semantic states, DISENGAGE,
  action buttons, glow helpers, backgroundDecoration getter)
- lib/theme/theme_registry.dart: ThemeRegistry with 4 factory themes,
  byId() fallback, architecture stub for Sprint 9 custom YAML themes
- lib/theme/theme_provider.dart: AutopilotThemeProvider (ChangeNotifier),
  SharedPreferences persistence under 'autopilot.theme.id', NOT sent to ESP32
- lib/theme/themes/: 4 factory themes with exact hex values from spec:
    light (cream/navy, accentGlowRadius=0 — daytime no-glow rule)
    cyan (deep navy/neon-cyan, default, glowRadius=16)
    wine (vinotinto, DISENGAGE=amber not red, glowRadius=18)
    ochre (warm brown/gold, okColor=lime for contrast, glowRadius=18)
- lib/screens/settings/appearance_settings.dart: Appearance screen with
  4-card theme previews (200x120px), 400ms AnimatedContainer transitions,
  triple-tap shortcut note, Sprint-5 placeholders for auto day/night and
  ambient light sensor toggles
- lib/widgets/themed/: 4 themed widgets consuming AutopilotThemeProvider:
    compass_rose.dart (heading arc, N mark, set-point tick, glow ring)
    disengage_button.dart (60x60 min touch target, gradient, glow)
    mode_selector.dart (STANDBY/HDG HOLD/TRACK with accent highlight)
    rudder_indicator.dart (horizontal bar -35° to +35°, accent knob)
- lib/main.dart: app entry point with ChangeNotifierProvider

Tests — display/test/theme/:
- theme_registry_test.dart: 4 themes load, correct IDs, display order,
  no null tokens, glow rules, backgroundGradient rules
- theme_provider_test.dart: default load=cyan, persistence across restarts,
  setTheme notifies listeners, unknown ID falls back to default
- theme_contrast_test.dart: WCAG checks — DISENGAGE ≥7:1 AAA,
  action buttons ≥4.5:1 AA, textMain ≥4.5:1 AA, setLight/okColor ≥3:1

Also:
- .gitignore: added !display/lib/ exception (lib/ was excluded for Python venv)

Design rules enforced:
- No glow in light mode (accentGlowRadius=0)
- DISENGAGE = amber in wine theme (red would blend into palette)
- North mark = warm colour on all themes (nautical convention)
- Touch targets: 48px nominal, 60px critical
- Theme not sent to ESP32, not synced between displays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:33:04 -04:00

131 lines
5.3 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:ar_autopilot_display/theme/theme_registry.dart';
void main() {
group('ThemeRegistry — factory themes', () {
test('exposes exactly 4 factory themes', () {
expect(ThemeRegistry.all.length, 4);
});
test('factory theme ids are light, cyan, wine, ochre', () {
final ids = ThemeRegistry.all.map((t) => t.id).toList();
expect(ids, containsAll(['light', 'cyan', 'wine', 'ochre']));
});
test('display order is light → cyan → wine → ochre', () {
final ids = ThemeRegistry.all.map((t) => t.id).toList();
expect(ids, ['light', 'cyan', 'wine', 'ochre']);
});
test('all themes have non-empty id and displayName', () {
for (final t in ThemeRegistry.all) {
expect(t.id, isNotEmpty, reason: 'id must not be empty');
expect(t.displayName, isNotEmpty, reason: 'displayName must not be empty');
}
});
group('byId', () {
test('returns correct theme for each known id', () {
for (final t in ThemeRegistry.all) {
final found = ThemeRegistry.byId(t.id);
expect(found.id, t.id, reason: 'byId("${t.id}") should return that theme');
}
});
test('falls back to default (cyan) for unknown id', () {
expect(ThemeRegistry.byId('does_not_exist').id, ThemeRegistry.defaultId);
expect(ThemeRegistry.byId('').id, ThemeRegistry.defaultId);
});
});
test('default id is cyan', () {
expect(ThemeRegistry.defaultId, 'cyan');
});
test('contains() returns true for factory ids', () {
for (final t in ThemeRegistry.all) {
expect(ThemeRegistry.contains(t.id), isTrue);
}
});
test('contains() returns false for unknown id', () {
expect(ThemeRegistry.contains('unknown_xyz'), isFalse);
});
group('Token completeness — no null colours', () {
for (final t in ThemeRegistry.all) {
test('${t.id}: all Color tokens are non-null', () {
// Backgrounds
expect(t.background, isNotNull, reason: '${t.id}.background');
expect(t.backgroundMid, isNotNull, reason: '${t.id}.backgroundMid');
expect(t.backgroundDeep, isNotNull, reason: '${t.id}.backgroundDeep');
expect(t.backgroundDeepest, isNotNull, reason: '${t.id}.backgroundDeepest');
expect(t.panelBackground, isNotNull, reason: '${t.id}.panelBackground');
expect(t.panelBorder, isNotNull, reason: '${t.id}.panelBorder');
// Text
expect(t.textMain, isNotNull, reason: '${t.id}.textMain');
expect(t.textMuted, isNotNull, reason: '${t.id}.textMuted');
expect(t.textSoft, isNotNull, reason: '${t.id}.textSoft');
expect(t.textDisabled, isNotNull, reason: '${t.id}.textDisabled');
// Accent
expect(t.accentLight, isNotNull, reason: '${t.id}.accentLight');
expect(t.accentMid, isNotNull, reason: '${t.id}.accentMid');
expect(t.accentDark, isNotNull, reason: '${t.id}.accentDark');
expect(t.accentGlowColor, isNotNull, reason: '${t.id}.accentGlowColor');
// Set-point
expect(t.setLight, isNotNull, reason: '${t.id}.setLight');
expect(t.setDark, isNotNull, reason: '${t.id}.setDark');
expect(t.setGlow, isNotNull, reason: '${t.id}.setGlow');
// Semantic
expect(t.okColor, isNotNull, reason: '${t.id}.okColor');
expect(t.warnColor, isNotNull, reason: '${t.id}.warnColor');
expect(t.northColor, isNotNull, reason: '${t.id}.northColor');
// Disengage
expect(t.disengageBackground, isNotNull, reason: '${t.id}.disengageBackground');
expect(t.disengageText, isNotNull, reason: '${t.id}.disengageText');
expect(t.disengageBorder, isNotNull, reason: '${t.id}.disengageBorder');
expect(t.disengageGlow, isNotNull, reason: '${t.id}.disengageGlow');
// Action buttons
expect(t.actionButtonBackground, isNotNull, reason: '${t.id}.actionButtonBackground');
expect(t.actionButtonBorder, isNotNull, reason: '${t.id}.actionButtonBorder');
expect(t.actionButtonText, isNotNull, reason: '${t.id}.actionButtonText');
expect(t.actionButtonGlow, isNotNull, reason: '${t.id}.actionButtonGlow');
});
}
});
group('Glow rules', () {
test('light theme has accentGlowRadius == 0 (no glow in daytime)', () {
expect(ThemeRegistry.byId('light').accentGlowRadius, 0.0);
});
test('dark themes have accentGlowRadius > 0', () {
for (final id in ['cyan', 'wine', 'ochre']) {
expect(
ThemeRegistry.byId(id).accentGlowRadius,
greaterThan(0),
reason: '$id should have glow',
);
}
});
});
group('Background gradient rules', () {
test('light theme has no backgroundGradient (solid colour)', () {
expect(ThemeRegistry.byId('light').backgroundGradient, isNull);
});
test('dark themes have a backgroundGradient', () {
for (final id in ['cyan', 'wine', 'ochre']) {
expect(
ThemeRegistry.byId(id).backgroundGradient,
isNotNull,
reason: '$id should have a radial background gradient',
);
}
});
});
});
}