MotorSignal-App/test/ui3_dashboard_test.dart

458 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<RideSession>
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<PerformanceLaunchAttempt>
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<TelemetrySample> telemetryController;
late StreamController<List<RoadAlert>> alertsController;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
telemetryController = StreamController<TelemetrySample>.broadcast();
alertsController = StreamController<List<RoadAlert>>.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);
});
});
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<BottomNavigationBar>(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');
});
});
}