import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.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/map/map_providers.dart'; import 'package:motorcycle_dash/services/map/unified_map_view.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); expect(find.text('PERFORMANCE LAUNCH'), 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, latitude: 37.7749, longitude: -122.4194, 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 LOCKED'), findsOneWidget); expect(find.text('25°'), findsOneWidget); expect(find.text('L: 0°'), findsOneWidget); expect(find.text('R: 25°'), findsOneWidget); expect(find.text('ACC: 0.42'), findsOneWidget); expect(find.text('BRK: 0.00'), findsOneWidget); telemetryController.add( TelemetrySample( timestamp: DateTime.now(), speed: 42.0, leanAngle: -18.2, longitudinalG: -0.31, lateralG: -0.12, ), ); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('18°'), findsOneWidget); expect(find.text('L: 18°'), findsOneWidget); expect(find.text('R: 0°'), findsOneWidget); expect(find.text('ACC: 0.00'), findsOneWidget); expect(find.text('BRK: 0.31'), 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); // RID-3C-2: the status chip is a health CLAIM, not a RideState claim. A ride // started here has no GPS/IMU/DB evidence yet, so the truthful label is // WARMING UP; RECORDING requires healthStatus == recordingActive. This test's // purpose is that the session SURVIVES the resize, so the same claim must // appear both before and after rotation. expect(find.text('WARMING UP'), findsOneWidget); expect(find.text('RECORDING'), findsNothing); // 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('WARMING UP'), findsOneWidget); expect(find.text('RECORDING'), findsNothing); }); 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('0.2 mi 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); }); }); group('BUG-001: Touring Map Rider Follow & Recenter Regression Tests', () { testWidgets( 'Touring map follows incoming GPS coordinates in portrait and landscape, pauses on manual pan, and resumes on recenter', (tester) async { await prefs.setString('dashboardLayout', 'touring'); configureViewport(tester, 400, 800); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget(buildTestDashboard()); await tester.pump(); expect(find.byType(DashboardMapPanel), findsOneWidget); expect(find.byType(FlutterMap), findsOneWidget); expect( find.byKey(const ValueKey('dashboard_map_recenter_button')), findsNothing, ); // Emit initial GPS fix within 50ms of mount (previously dropped by 500ms cooldown) telemetryController.add( TelemetrySample( timestamp: DateTime.now(), speed: 35.0, heading: 90.0, latitude: 34.0522, longitude: -118.2437, gpsAccuracy: 5.0, gpsAgeMs: 100, ), ); await tester.pump(const Duration(milliseconds: 50)); var unifiedMap = tester.widget( find.byType(UnifiedMapView), ); var flutterMap = tester.widget(find.byType(FlutterMap)); expect(unifiedMap.center.latitude, closeTo(34.0522, 0.0001)); expect(unifiedMap.center.longitude, closeTo(-118.2437, 0.0001)); expect( flutterMap.mapController!.camera.center.latitude, closeTo(34.0522, 0.0001), ); expect( flutterMap.mapController!.camera.center.longitude, closeTo(-118.2437, 0.0001), ); // Emit rapid subsequent GPS fix within 100ms (tests trailing timer follow) telemetryController.add( TelemetrySample( timestamp: DateTime.now(), speed: 45.0, heading: 95.0, latitude: 34.0526, longitude: -118.2431, gpsAccuracy: 5.0, gpsAgeMs: 80, ), ); await tester.pump(const Duration(milliseconds: 50)); // Advance past the 250ms trailing follow timer window await tester.pump(const Duration(milliseconds: 300)); flutterMap = tester.widget(find.byType(FlutterMap)); expect( flutterMap.mapController!.camera.center.latitude, closeTo(34.0526, 0.0001), ); expect( flutterMap.mapController!.camera.center.longitude, closeTo(-118.2431, 0.0001), ); // Simulate user manual pan / gesture interaction on the Touring map final element = tester.element(find.byType(DashboardMapPanel)); final container = ProviderScope.containerOf(element); container.read(mapFollowStateProvider.notifier).userInteracted(); flutterMap.mapController!.move( flutterMap.mapController!.camera.center, 16.5, ); await tester.pump(); // Recenter button must appear when follow mode is disabled final recenterFinder = find.byKey( const ValueKey('dashboard_map_recenter_button'), ); expect(recenterFinder, findsOneWidget); // While panned (follow mode false), incoming GPS updates move the rider marker // but do NOT hijack the user's panned camera center final pannedCameraCenter = flutterMap.mapController!.camera.center; telemetryController.add( TelemetrySample( timestamp: DateTime.now(), speed: 50.0, heading: 100.0, latitude: 34.0600, longitude: -118.2500, gpsAccuracy: 5.0, gpsAgeMs: 80, ), ); await tester.pump(const Duration(milliseconds: 300)); flutterMap = tester.widget(find.byType(FlutterMap)); expect( flutterMap.mapController!.camera.center.latitude, closeTo(pannedCameraCenter.latitude, 0.0001), ); expect( flutterMap.mapController!.camera.center.longitude, closeTo(pannedCameraCenter.longitude, 0.0001), ); // Tap RECENTER button -> camera snaps back to latest rider position (34.0600, -118.2500) await tester.tap(recenterFinder); await tester.pump(); expect(recenterFinder, findsNothing); flutterMap = tester.widget(find.byType(FlutterMap)); expect( flutterMap.mapController!.camera.center.latitude, closeTo(34.0600, 0.0001), ); expect( flutterMap.mapController!.camera.center.longitude, closeTo(-118.2500, 0.0001), ); // Rotate to landscape Touring mode and verify GPS follow continues cleanly configureViewport(tester, 800, 360); await tester.pump(); telemetryController.add( TelemetrySample( timestamp: DateTime.now(), speed: 55.0, heading: 110.0, latitude: 34.0700, longitude: -118.2600, gpsAccuracy: 5.0, gpsAgeMs: 80, ), ); await tester.pump(const Duration(milliseconds: 300)); flutterMap = tester.widget(find.byType(FlutterMap)); expect( flutterMap.mapController!.camera.center.latitude, closeTo(34.0700, 0.0001), ); expect( flutterMap.mapController!.camera.center.longitude, closeTo(-118.2600, 0.0001), ); }, ); }); } 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); }