376 lines
13 KiB
Dart
376 lines
13 KiB
Dart
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<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 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 = <String, Size>{
|
|
'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 3D FIX'), 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('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<RideSession>
|
|
implements RideStateMachine {
|
|
_TestRideStateMachine([RideSession? initial])
|
|
: super(
|
|
initial ??
|
|
RideSession(state: RideState.idle, startTime: DateTime.now()),
|
|
);
|
|
|
|
@override
|
|
Future<void> startRide() async {
|
|
state = state.copyWith(
|
|
state: RideState.riding,
|
|
startTime: DateTime.now(),
|
|
tripDistanceMiles: 0.0,
|
|
topSpeedMph: 0.0,
|
|
averageSpeedMph: 0.0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> finishRide() async {
|
|
state = state.copyWith(state: RideState.finished);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|