import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:motorcycle_dash/core/theme/theme.dart'; import 'package:motorcycle_dash/features/dashboard/dashboard_config.dart'; import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/dashboard_map_panel.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/g_force_indicator.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/gps_status_indicator.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/heading_indicator.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/ride_action_button.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/ride_status_chip.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/speed_readout.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late SharedPreferences prefs; late StreamController telemetryController; late StreamController> alertsController; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); telemetryController = StreamController.broadcast(); alertsController = StreamController>.broadcast(); }); tearDown(() async { await telemetryController.close(); await alertsController.close(); }); Widget buildTestDashboard({ ThemeMode themeMode = ThemeMode.dark, RideSession? initialSession, DashboardLayout initialLayout = DashboardLayout.sport, bool hasLocationPermission = true, }) { return ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream), locationPermissionGrantedProvider.overrideWith( (ref) => hasLocationPermission, ), rideStateMachineProvider.overrideWith( (ref) => _TestRideStateMachine(initialSession), ), ], child: MaterialApp( theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: themeMode, home: const DashboardScreen(), ), ); } void configureViewport(WidgetTester tester, double width, double height) { tester.view.physicalSize = Size(width, height); tester.view.devicePixelRatio = 1.0; } group('Canonical Dashboard Viewport Matrix Tests', () { final viewports = { '320x640 (Small Compact Phone)': const Size(320, 640), '320x520 (Constrained Small Phone)': const Size(320, 520), '360x460 (Transient IME/Sheet Inset)': const Size(360, 460), '412x460 (Pixel 8a Inset Viewport)': const Size(412, 460), '390x480 (Constrained Portrait Phone)': const Size(390, 480), '400x800 (Standard Phone Portrait)': const Size(400, 800), '412x915 (Pixel-class Portrait)': const Size(412, 915), '780x360 (Short Landscape Mount)': const Size(780, 360), '800x360 (Cockpit Mount)': const Size(800, 360), '840x390 (Modern Landscape Phone)': const Size(840, 390), '600x900 (Medium Tablet Portrait)': const Size(600, 900), '840x600 (Medium Tablet Landscape)': const Size(840, 600), '1024x768 (Large Tablet Landscape)': const Size(1024, 768), }; for (final entry in viewports.entries) { testWidgets('Renders cleanly on ${entry.key}', (tester) async { configureViewport(tester, entry.value.width, entry.value.height); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); expect(tester.takeException(), isNull, reason: 'Zero RenderFlex overflow on ${entry.key}'); expect(find.byType(SpeedReadout), findsOneWidget); expect(find.byType(HeadingIndicator), findsOneWidget); expect(find.byType(GPSStatusIndicator), findsOneWidget); expect(find.byType(RideStatusChip), findsOneWidget); expect(find.byType(RideActionButton), findsOneWidget); }); } }); group('Dashboard Theme Tests', () { testWidgets('Renders properly in Light Mode', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard(themeMode: ThemeMode.light)); await tester.pump(); expect(tester.takeException(), isNull); expect(find.byType(SpeedReadout), findsOneWidget); expect(find.text('START SESSION'), findsOneWidget); }); testWidgets('Renders properly in Dark Mode', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard(themeMode: ThemeMode.dark)); await tester.pump(); expect(tester.takeException(), isNull); expect(find.byType(SpeedReadout), findsOneWidget); expect(find.text('START SESSION'), findsOneWidget); }); }); group('Dashboard Layout Modes (Sport, Touring, Minimal)', () { testWidgets('Sport Mode displays Lean and G-Force indicators', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); expect(find.byType(GForceIndicator), findsOneWidget); expect(find.byType(LeanAngleIndicator), findsOneWidget); }); testWidgets('Minimal Mode displays Speedometer and minimal controls', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await prefs.setString('dashboardLayout', 'minimal'); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); expect(find.byType(SpeedReadout), findsOneWidget); expect(find.byType(GForceIndicator), findsNothing); expect(find.byType(LeanAngleIndicator), findsNothing); expect(find.byType(RideActionButton), findsOneWidget); }); testWidgets('Touring Mode is mode-driven: displays map in both portrait and landscape', (tester) async { await prefs.setString('dashboardLayout', 'touring'); // Portrait configureViewport(tester, 400, 800); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); expect(tester.takeException(), isNull); expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in portrait Touring'); expect(find.text('MOVING'), findsOneWidget); // Landscape configureViewport(tester, 800, 360); await tester.pump(); expect(tester.takeException(), isNull); expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in landscape Touring'); }); }); group('Performance & Rebuild Isolation Tests', () { testWidgets('Telemetry updates speed readout without destroying ride state', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); expect(find.text('0'), findsWidgets); // Emit high-frequency sample telemetryController.add( TelemetrySample( timestamp: DateTime.now(), speed: 68.4, heading: 270.0, leanAngle: 24.5, longitudinalG: 0.42, lateralG: 0.35, gpsAccuracy: 9.0, ), ); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('68'), findsOneWidget); expect(find.text('W'), findsOneWidget); expect(find.text('270°'), findsOneWidget); expect(find.text('GPS 3D FIX'), findsOneWidget); }); testWidgets('Resizing viewport preserves active ride session', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); // Start ride await tester.tap(find.byType(RideActionButton)); await tester.pump(); expect(find.text('STOP SESSION'), findsOneWidget); expect(find.text('RECORDING'), findsOneWidget); // Rotate to landscape configureViewport(tester, 800, 360); await tester.pump(); // Verify ride did NOT stop or reset expect(find.text('STOP SESSION'), findsOneWidget); expect(find.text('RECORDING'), findsOneWidget); }); testWidgets('Alert region displays alert banner and handles dismissal', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); alertsController.add([ RoadAlert( id: 'test_alert_1', providerId: 'here_maps', category: RoadAlertCategory.hazard, latitude: 37.77, longitude: -122.42, distanceFromUser: 250, roadName: 'Skyline Blvd', description: 'Obstacle in road', reportedAt: DateTime.now(), lastUpdated: DateTime.now(), source: 'here', ), ]); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('HAZARD'), findsOneWidget); expect(find.text('250m Ahead • Skyline Blvd'), findsOneWidget); expect(find.byIcon(Icons.close), findsOneWidget); }); testWidgets('Displays location permission banner when permission denied', (tester) async { configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard(hasLocationPermission: false)); await tester.pump(); expect(find.text('Location permission needed for GPS speedometer.'), findsOneWidget); expect(find.text('Enable'), findsOneWidget); }); }); } class _TestRideStateMachine extends StateNotifier implements RideStateMachine { _TestRideStateMachine([RideSession? initial]) : super( initial ?? RideSession( state: RideState.idle, startTime: DateTime.now(), ), ); @override Future startRide() async { state = state.copyWith( state: RideState.riding, startTime: DateTime.now(), tripDistanceMiles: 0.0, topSpeedMph: 0.0, averageSpeedMph: 0.0, ); } @override Future finishRide() async { state = state.copyWith( state: RideState.finished, ); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }