MotorSignal-App/test/canonical_dashboard_respons...

313 lines
11 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);
});
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,
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);
});
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);
expect(find.text('RECORDING'), findsOneWidget);
// 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('RECORDING'), findsOneWidget);
});
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);
}