import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:motorcycle_dash/core/theme/theme.dart'; import 'package:motorcycle_dash/core/widgets/main_layout.dart'; import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart'; import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart'; import 'package:motorcycle_dash/features/performance/performance_providers.dart'; import 'package:motorcycle_dash/features/performance/services/performance_launch_state_machine.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'; class _TestRideStateMachine extends StateNotifier implements RideStateMachine { _TestRideStateMachine([RideSession? initial]) : super( initial ?? RideSession(state: RideState.idle, startTime: DateTime.now()), ); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _TestPerformanceLaunchStateMachine extends StateNotifier implements PerformanceLaunchStateMachine { _TestPerformanceLaunchStateMachine([PerformanceLaunchAttempt? initial]) : super(initial ?? const PerformanceLaunchAttempt()); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } 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 buildDashboardHarness({ ThemeMode themeMode = ThemeMode.dark, RideSession? session, PerformanceLaunchAttempt? attempt, GoRouter? customRouter, }) { final effectiveSession = session ?? RideSession( state: RideState.idle, startTime: DateTime.now(), maxLeanLeft: 32.0, maxLeanRight: 28.0, ); final effectiveAttempt = attempt ?? const PerformanceLaunchAttempt(); final router = customRouter ?? GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', builder: (context, state) => const DashboardScreen(), ), GoRoute( path: '/launch', builder: (context, state) => const Scaffold( body: Text('PERFORMANCE_LAUNCH_SCREEN_DESTINATION'), ), ), ], ); return ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream), locationPermissionGrantedProvider.overrideWith((ref) => true), rideStateMachineProvider.overrideWith((ref) => _TestRideStateMachine(effectiveSession)), performanceLaunchProvider.overrideWith((ref) => _TestPerformanceLaunchStateMachine(effectiveAttempt)), ], child: MaterialApp.router( theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: themeMode, routerConfig: router, ), ); } group('UI-3: Lean Angle Motorcycle Gauge', () { testWidgets('Lean gauge renders CENTER when angle is upright', (tester) async { await tester.binding.setSurfaceSize(const Size(412, 892)); await tester.pumpWidget( ProviderScope( overrides: [ telemetryStreamProvider.overrideWith( (ref) => Stream.value( TelemetrySample( timestamp: DateTime.now(), leanAngle: 0.0, speed: 35.0, ), ), ), rideStateMachineProvider.overrideWith( (ref) => _TestRideStateMachine( RideSession( state: RideState.riding, startTime: DateTime.now(), maxLeanLeft: 32.0, maxLeanRight: 28.0, ), ), ), ], child: const MaterialApp( home: Scaffold(body: LeanAngleIndicator()), ), ), ); await tester.pumpAndSettle(); expect(find.text('LEAN ANGLE'), findsOneWidget); expect(find.text('0°'), findsOneWidget); expect(find.text('CENTER'), findsOneWidget); expect(find.text('MAX L 32°'), findsOneWidget); expect(find.text('MAX R 28°'), findsOneWidget); }); testWidgets('Lean gauge renders LEFT with correct degrees when leaning left', (tester) async { await tester.binding.setSurfaceSize(const Size(412, 892)); await tester.pumpWidget( ProviderScope( overrides: [ telemetryStreamProvider.overrideWith( (ref) => Stream.value( TelemetrySample( timestamp: DateTime.now(), leanAngle: -17.4, speed: 42.0, ), ), ), rideStateMachineProvider.overrideWith( (ref) => _TestRideStateMachine( RideSession( state: RideState.riding, startTime: DateTime.now(), maxLeanLeft: 35.0, maxLeanRight: 30.0, ), ), ), ], child: const MaterialApp( home: Scaffold(body: LeanAngleIndicator()), ), ), ); await tester.pumpAndSettle(); expect(find.text('17°'), findsOneWidget); expect(find.text('LEFT'), findsOneWidget); expect(find.text('MAX L 35°'), findsOneWidget); expect(find.text('MAX R 30°'), findsOneWidget); }); testWidgets('Lean gauge renders RIGHT with correct degrees when leaning right', (tester) async { await tester.binding.setSurfaceSize(const Size(412, 892)); await tester.pumpWidget( ProviderScope( overrides: [ telemetryStreamProvider.overrideWith( (ref) => Stream.value( TelemetrySample( timestamp: DateTime.now(), leanAngle: 24.8, speed: 48.0, ), ), ), rideStateMachineProvider.overrideWith( (ref) => _TestRideStateMachine( RideSession( state: RideState.riding, startTime: DateTime.now(), maxLeanLeft: 35.0, maxLeanRight: 30.0, ), ), ), ], child: const MaterialApp( home: Scaffold(body: LeanAngleIndicator()), ), ), ); await tester.pumpAndSettle(); expect(find.text('25°'), findsOneWidget); expect(find.text('RIGHT'), findsOneWidget); }); testWidgets('Lean gauge works in both Light and Dark modes without errors', (tester) async { for (final mode in [ThemeMode.light, ThemeMode.dark]) { await tester.pumpWidget( ProviderScope( overrides: [ telemetryStreamProvider.overrideWith( (ref) => Stream.value( TelemetrySample( timestamp: DateTime.now(), leanAngle: 12.0, speed: 25.0, ), ), ), rideStateMachineProvider.overrideWith( (ref) => _TestRideStateMachine( RideSession( state: RideState.riding, startTime: DateTime.now(), maxLeanLeft: 20.0, maxLeanRight: 22.0, ), ), ), ], child: MaterialApp( theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: mode, home: const Scaffold(body: LeanAngleIndicator()), ), ), ); await tester.pumpAndSettle(); expect(find.text('LEAN ANGLE'), findsOneWidget); expect(find.text('12°'), findsOneWidget); expect(find.text('RIGHT'), findsOneWidget); expect(tester.takeException(), isNull); } }); }); void configureViewport(WidgetTester tester, double width, double height) { tester.view.physicalSize = Size(width, height); tester.view.devicePixelRatio = 1.0; } group('UI-3: Performance Launch Card on Dashboard', () { testWidgets('Performance Launch card is visible on dashboard and navigates on tap', (tester) async { configureViewport(tester, 412, 892); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget( buildDashboardHarness( attempt: const PerformanceLaunchAttempt( state: PerformanceLaunchState.idle, ), ), ); await tester.pumpAndSettle(); // Card is present final cardFinder = find.byKey(const ValueKey('dashboard_performance_launch_card')); expect(cardFinder, findsOneWidget); expect(find.text('PERFORMANCE LAUNCH'), findsOneWidget); expect(find.text('0–60 MPH • 0–100 MPH'), findsOneWidget); expect(find.text('READY'), findsOneWidget); // Tap card await tester.tap(cardFinder); await tester.pumpAndSettle(); // Navigated to /launch expect(find.text('PERFORMANCE_LAUNCH_SCREEN_DESTINATION'), findsOneWidget); }); testWidgets('Performance Launch card correctly displays ARMED state', (tester) async { configureViewport(tester, 412, 892); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget( buildDashboardHarness( attempt: const PerformanceLaunchAttempt( state: PerformanceLaunchState.ready, ), ), ); await tester.pumpAndSettle(); expect(find.text('ARMED'), findsOneWidget); }); testWidgets('Performance Launch card correctly displays RUNNING state', (tester) async { configureViewport(tester, 412, 892); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget( buildDashboardHarness( attempt: const PerformanceLaunchAttempt( state: PerformanceLaunchState.launched, ), ), ); await tester.pumpAndSettle(); expect(find.text('RUNNING'), findsOneWidget); }); testWidgets('Performance Launch card correctly displays FALSE START state', (tester) async { configureViewport(tester, 412, 892); addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget( buildDashboardHarness( attempt: const PerformanceLaunchAttempt( state: PerformanceLaunchState.aborted, isFalseStart: true, ), ), ); await tester.pumpAndSettle(); expect(find.text('FALSE START'), findsOneWidget); }); testWidgets('Pixel 8a portrait has no RenderFlex overflow with lean gauge and launch card', (tester) async { configureViewport(tester, 412, 892); // Exact Pixel 8a portrait addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget( buildDashboardHarness( session: RideSession( state: RideState.riding, startTime: DateTime.now(), tripDistanceMiles: 14.25, topSpeedMph: 72.0, averageSpeedMph: 44.5, maxLeanLeft: 34.0, maxLeanRight: 31.0, ), attempt: const PerformanceLaunchAttempt( state: PerformanceLaunchState.ready, ), ), ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('dashboard_performance_launch_card')), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('Short portrait viewports (usable height < 760) retain Performance Launch card in compact form', (tester) async { configureViewport(tester, 412, 700); // Usable height inside MainLayout with bottom navigation on Pixel 8a addTearDown(() => tester.view.resetPhysicalSize()); await tester.pumpWidget( buildDashboardHarness( session: RideSession( state: RideState.idle, startTime: DateTime.now(), ), attempt: const PerformanceLaunchAttempt( state: PerformanceLaunchState.ready, ), ), ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('dashboard_performance_launch_card')), findsOneWidget); expect(find.text('PERFORMANCE LAUNCH'), findsOneWidget); expect(tester.takeException(), isNull); }); }); group('UI-3: Bottom Navigation Frozen Invariant', () { testWidgets('Bottom navigation retains exactly 6 tabs: Dash, Map, Activity, Garage, Social, Settings', (tester) async { configureViewport(tester, 412, 892); addTearDown(() => tester.view.resetPhysicalSize()); final router = GoRouter( initialLocation: '/', routes: [ StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { return MainLayout(navigationShell: navigationShell); }, branches: [ StatefulShellBranch( routes: [GoRoute(path: '/', builder: (context, state) => const SizedBox())], ), StatefulShellBranch( routes: [GoRoute(path: '/map', builder: (context, state) => const SizedBox())], ), StatefulShellBranch( routes: [GoRoute(path: '/activity', builder: (context, state) => const SizedBox())], ), StatefulShellBranch( routes: [GoRoute(path: '/garage', builder: (context, state) => const SizedBox())], ), StatefulShellBranch( routes: [GoRoute(path: '/social', builder: (context, state) => const SizedBox())], ), StatefulShellBranch( routes: [GoRoute(path: '/settings', builder: (context, state) => const SizedBox())], ), ], ), ], ); await tester.pumpWidget( ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream), locationPermissionGrantedProvider.overrideWith((ref) => true), rideStateMachineProvider.overrideWith( (ref) => _TestRideStateMachine( RideSession( state: RideState.idle, startTime: DateTime.now(), ), ), ), ], child: MaterialApp.router( theme: AppTheme.darkTheme, routerConfig: router, ), ), ); await tester.pumpAndSettle(); final navBarFinder = find.byType(BottomNavigationBar); expect(navBarFinder, findsOneWidget); final navBar = tester.widget(navBarFinder); expect(navBar.items.length, 6); expect(navBar.items[0].label, 'Dash'); expect(navBar.items[1].label, 'Map'); expect(navBar.items[2].label, 'Activity'); expect(navBar.items[3].label, 'Garage'); expect(navBar.items[4].label, 'Social'); expect(navBar.items[5].label, 'Settings'); }); }); }