458 lines
16 KiB
Dart
458 lines
16 KiB
Dart
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');
|
||
});
|
||
});
|
||
}
|