MotorSignal-App/test/dashboard_landscape_respons...

249 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:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/features/dashboard/dashboard_screen.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/performance_summary.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/responsive_dashboard_layout.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';
import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/quick_squad_feed_button.dart';
import 'package:shared_preferences/shared_preferences.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({
RideSession? initialSession,
List<RoadAlert>? initialAlerts,
}) {
return ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream),
dashboardAlertsStreamProvider.overrideWith((ref) => alertsController.stream),
locationPermissionGrantedProvider.overrideWith((ref) => true),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const DashboardScreen(),
),
);
}
void configureViewport(WidgetTester tester, double width, double height) {
tester.view.physicalSize = Size(width, height);
tester.view.devicePixelRatio = 1.0;
}
group('Landscape Dashboard Redesign Matrix Tests', () {
testWidgets('Renders adaptive 3-column composition on 840x390 landscape phone', (tester) async {
configureViewport(tester, 840, 390);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
// Zero RenderFlex overflow
expect(tester.takeException(), isNull);
// Verify Left Column: G-Force indicator & statistics tiles
expect(find.byType(GForceIndicator), findsOneWidget);
expect(find.text('TRIP'), findsOneWidget);
expect(find.text('TOP'), findsOneWidget);
expect(find.text('AVG'), findsOneWidget);
// Verify Center Column: Speedometer, Status row, Controls
expect(find.byType(SpeedReadout), findsOneWidget);
expect(find.byType(GPSStatusIndicator), findsOneWidget);
expect(find.byType(HeadingIndicator), findsOneWidget);
expect(find.byType(RideStatusChip), findsOneWidget);
expect(find.byType(RideActionButton), findsOneWidget);
expect(find.byType(PerformanceLaunchShortcut), findsOneWidget);
// Verify Right Column: Lean-angle gauge & dynamics
expect(find.byType(LeanAngleIndicator), findsOneWidget);
expect(find.text('MOVING'), findsOneWidget);
expect(find.text('ELEV'), findsOneWidget);
expect(find.byType(PerformanceSummary), findsOneWidget);
// Verify horizontal order: GForce (left) < Speed (center) < LeanAngle (right)
final gForceCenter = tester.getCenter(find.byType(GForceIndicator));
final speedCenter = tester.getCenter(find.byType(SpeedReadout));
final leanCenter = tester.getCenter(find.byType(LeanAngleIndicator));
expect(gForceCenter.dx, lessThan(speedCenter.dx));
expect(speedCenter.dx, lessThan(leanCenter.dx));
});
testWidgets('Landscape alert banner is compact (<= 40dp height) and non-disruptive', (tester) async {
configureViewport(tester, 840, 390);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
final now = DateTime.now();
final alert = RoadAlert(
id: 'police_ahead_1',
providerId: 'waze_zyla',
category: RoadAlertCategory.police,
latitude: 34.05,
longitude: -84.40,
distanceFromUser: 450,
roadName: 'GA 400 Southbound',
description: 'Police Speed Trap',
reportedAt: now,
lastUpdated: now,
source: 'Waze',
);
alertsController.add([alert]);
await tester.pump(const Duration(milliseconds: 50));
expect(tester.takeException(), isNull);
expect(find.text('POLICE'), findsOneWidget);
expect(find.textContaining('0.3 mi Ahead'), findsOneWidget);
// Verify banner height is constrained to <= 40dp
final bannerFinder = find.byWidgetPredicate(
(widget) => widget is Container && widget.constraints?.maxHeight == 38 ||
(widget is Container && widget.decoration is BoxDecoration && (widget.decoration as BoxDecoration).border?.bottom != null),
);
expect(bannerFinder, findsWidgets);
// Speedometer remains visible and unobstructed
expect(find.byType(SpeedReadout), findsOneWidget);
expect(tester.getCenter(find.byType(SpeedReadout)).dy, greaterThan(60));
});
testWidgets('Preserves clear margin on right edge (>= 16dp) in landscape for volume overlay', (tester) async {
configureViewport(tester, 840, 390);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
// Find the ResponsiveDashboardLayout Padding widget
final paddingFinder = find.ancestor(
of: find.byType(ResponsiveDashboardLayout),
matching: find.byType(Padding),
);
expect(paddingFinder, findsWidgets);
final paddingWidget = tester.firstWidget<Padding>(find.descendant(
of: find.byType(ResponsiveDashboardLayout),
matching: find.byType(Padding),
));
final edgeInsets = paddingWidget.padding as EdgeInsets;
expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
});
testWidgets('BUG-007: Short landscape cockpit mount includes incident report and squad buttons with balanced layout', (tester) async {
configureViewport(tester, 800, 360);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byType(QuickSwarmReportButton), findsOneWidget);
expect(find.byType(QuickSquadFeedButton), findsOneWidget);
expect(find.byType(SpeedReadout), findsOneWidget);
});
testWidgets('BUG-007 / #54: Start Session button text is fully visible without clipping in minimal landscape (map hidden)', (tester) async {
await prefs.setString('dashboardLayout', 'minimal');
for (final size in [const Size(640, 360), const Size(800, 360), const Size(840, 390)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
final startBtnText = find.text('Start Session');
expect(startBtnText, findsOneWidget);
// Verify button text has positive width and fits cleanly
final textRenderBox = tester.renderObject(startBtnText) as RenderBox;
expect(textRenderBox.size.width, greaterThan(0));
expect(textRenderBox.size.height, greaterThan(0));
}
tester.view.resetPhysicalSize();
});
testWidgets('BUG-007 / #54: Start Session button text is fully visible in sport and touring landscape layouts', (tester) async {
for (final layout in ['sport', 'touring']) {
await prefs.setString('dashboardLayout', layout);
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('Start Session'), findsOneWidget);
}
}
tester.view.resetPhysicalSize();
});
testWidgets('BUG-007 / #54: LEAN ANGLE title does not overlap rider graphic across landscape viewports', (tester) async {
await prefs.setString('dashboardLayout', 'sport');
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
final titleFinder = find.text('LEAN ANGLE');
final graphicFinder = find.byKey(const ValueKey('lean_angle_rider_graphic'));
expect(titleFinder, findsOneWidget);
expect(graphicFinder, findsOneWidget);
final titleRect = tester.getRect(titleFinder);
final graphicRect = tester.getRect(graphicFinder);
// Prove no overlap between title rect and rider graphic rect
expect(titleRect.overlaps(graphicRect), isFalse, reason: 'Title overlaps rider graphic at ${size.width}x${size.height}');
// Prove title is positioned strictly above graphic
expect(titleRect.bottom, lessThanOrEqualTo(graphicRect.top), reason: 'Title bottom must be above or at graphic top at ${size.width}x${size.height}');
}
tester.view.resetPhysicalSize();
});
});
}