MotorSignal-App/test/rfr1_routing_test.dart

397 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/routes/route_planner_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/services/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
import 'fake_route_provider.dart';
void main() {
group('RFR-1: Route Ordering & Geometry Unit Tests', () {
test('Coordinate validation rejects invalid, NaN, Infinity, and (0,0)', () {
expect(RoutePlannerNotifier.isValidCoordinate(37.7749, -122.4194), isTrue);
expect(RoutePlannerNotifier.isValidCoordinate(-90.0, -180.0), isTrue);
expect(RoutePlannerNotifier.isValidCoordinate(90.0, 180.0), isTrue);
// Invalid bounds
expect(RoutePlannerNotifier.isValidCoordinate(91.0, 0.0), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(-91.0, 0.0), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(0.0, 181.0), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(0.0, -181.0), isFalse);
// Null / NaN / Infinity
expect(RoutePlannerNotifier.isValidCoordinate(null, -122.0), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(37.0, null), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(double.nan, -122.0), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(37.0, double.nan), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(double.infinity, -122.0), isFalse);
expect(RoutePlannerNotifier.isValidCoordinate(37.0, double.infinity), isFalse);
// (0, 0) placeholder
expect(RoutePlannerNotifier.isValidCoordinate(0.0, 0.0), isFalse);
});
test('RoutePlannerNotifier: start + destination triggers road calculation', () async {
final fake = FakeRouteProvider();
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
);
addTearDown(container.dispose);
container.listen(routePlannerProvider(null), (_, _) {});
final notifier = container.read(routePlannerProvider(null).notifier);
final origin = RouteStop(
id: 'stop-1',
displayName: 'Start Location',
latitude: 37.7749,
longitude: -122.4194,
order: 0,
type: RouteStopType.origin,
);
final destination = RouteStop(
id: 'stop-2',
displayName: 'Destination Location',
latitude: 37.3382,
longitude: -121.8863,
order: 1,
type: RouteStopType.destination,
);
notifier.addStop(stop: origin);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(container.read(routePlannerProvider(null)).stops.length, 1);
expect(container.read(routePlannerProvider(null)).geometry, isEmpty);
notifier.addStop(stop: destination);
await Future<void>.delayed(const Duration(milliseconds: 100));
final state = container.read(routePlannerProvider(null));
expect(state.stops.length, 2);
expect(state.geometry.isNotEmpty, isTrue);
expect(state.geometry.length, 2);
expect(state.geometry.first.latitude, closeTo(37.7749, 0.001));
expect(state.geometry.last.latitude, closeTo(37.3382, 0.001));
});
test('RoutePlannerNotifier: start + stop + destination preserves exact canonical order', () async {
final fake = FakeRouteProvider();
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
);
addTearDown(container.dispose);
container.listen(routePlannerProvider(null), (_, _) {});
final notifier = container.read(routePlannerProvider(null).notifier);
final origin = RouteStop(
id: 'origin',
displayName: 'San Francisco',
latitude: 37.7749,
longitude: -122.4194,
order: 0,
type: RouteStopType.origin,
);
final destination = RouteStop(
id: 'destination',
displayName: 'San Jose',
latitude: 37.3382,
longitude: -121.8863,
order: 1,
type: RouteStopType.destination,
);
notifier.addStop(stop: origin);
notifier.addStop(stop: destination);
await Future<void>.delayed(const Duration(milliseconds: 100));
// Intermediate stop added: Must be placed BEFORE destination, NOT after
final intermediate = RouteStop(
id: 'mid',
displayName: 'Palo Alto',
latitude: 37.4419,
longitude: -122.1430,
order: 2,
type: RouteStopType.waypoint,
);
notifier.addStop(stop: intermediate);
await Future<void>.delayed(const Duration(milliseconds: 100));
final state = container.read(routePlannerProvider(null));
expect(state.stops.length, 3);
// Canonical order: origin (0) -> intermediate (1) -> destination (2)
expect(state.stops[0].id, 'origin');
expect(state.stops[0].order, 0);
expect(state.stops[0].type, RouteStopType.origin);
expect(state.stops[1].id, 'mid');
expect(state.stops[1].order, 1);
expect(state.stops[1].type, RouteStopType.waypoint);
expect(state.stops[2].id, 'destination');
expect(state.stops[2].order, 2);
expect(state.stops[2].type, RouteStopType.destination);
// FakeRouteProvider received ordered stops
expect(fake.lastCapturedStops, isNotNull);
expect(fake.lastCapturedStops!.map((s) => s.id).toList(), ['origin', 'mid', 'destination']);
// Geometry has all 3 points in order
expect(state.geometry.length, 3);
expect(state.geometry[0].latitude, closeTo(37.7749, 0.001));
expect(state.geometry[1].latitude, closeTo(37.4419, 0.001));
expect(state.geometry[2].latitude, closeTo(37.3382, 0.001));
});
test('RoutePlannerNotifier: multiple stops preserve order', () async {
final fake = FakeRouteProvider();
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
);
addTearDown(container.dispose);
container.listen(routePlannerProvider(null), (_, _) {});
final notifier = container.read(routePlannerProvider(null).notifier);
notifier.addStop(stop: const RouteStop(id: 'start', displayName: 'Start', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin));
notifier.addStop(stop: const RouteStop(id: 'end', displayName: 'End', latitude: 37.9, longitude: -122.9, order: 1, type: RouteStopType.destination));
notifier.addStop(stop: const RouteStop(id: 'stop1', displayName: 'Stop 1', latitude: 37.2, longitude: -122.2, order: 2, type: RouteStopType.waypoint));
notifier.addStop(stop: const RouteStop(id: 'stop2', displayName: 'Stop 2', latitude: 37.4, longitude: -122.4, order: 3, type: RouteStopType.waypoint));
await Future<void>.delayed(const Duration(milliseconds: 100));
final stops = container.read(routePlannerProvider(null)).stops;
expect(stops.length, 4);
expect(stops.map((s) => s.id).toList(), ['start', 'stop1', 'stop2', 'end']);
for (int i = 0; i < stops.length; i++) {
expect(stops[i].order, i);
}
});
test('RoutePlannerNotifier: stop addition invalidates/replaces stale geometry', () async {
final fake = FakeRouteProvider();
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
);
addTearDown(container.dispose);
container.listen(routePlannerProvider(null), (_, _) {});
final notifier = container.read(routePlannerProvider(null).notifier);
notifier.addStop(stop: const RouteStop(id: 'A', displayName: 'A', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin));
notifier.addStop(stop: const RouteStop(id: 'B', displayName: 'B', latitude: 38.0, longitude: -122.0, order: 1, type: RouteStopType.destination));
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(container.read(routePlannerProvider(null)).geometry.length, 2);
// Adding another stop must update geometry to length 3
notifier.addStop(stop: const RouteStop(id: 'C', displayName: 'C', latitude: 37.5, longitude: -122.0, order: 2, type: RouteStopType.waypoint));
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(container.read(routePlannerProvider(null)).geometry.length, 3);
});
test('RoutePlannerNotifier: route calculation failure clears stale geometry and sets error', () async {
final fake = FakeRouteProvider(shouldFail: true);
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
);
addTearDown(container.dispose);
container.listen(routePlannerProvider(null), (_, _) {});
final notifier = container.read(routePlannerProvider(null).notifier);
notifier.addStop(stop: const RouteStop(id: 'A', displayName: 'A', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin));
notifier.addStop(stop: const RouteStop(id: 'B', displayName: 'B', latitude: 38.0, longitude: -122.0, order: 1, type: RouteStopType.destination));
await Future<void>.delayed(const Duration(milliseconds: 100));
final state = container.read(routePlannerProvider(null));
// Stale geometry MUST be cleared on failure
expect(state.geometry, isEmpty);
expect(state.routeCalculationError, isNotNull);
expect(state.routeCalculationError, contains('Fake route not found'));
});
test('No fallback coordinate contamination: rejected invalid stop', () {
final fake = FakeRouteProvider();
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
);
addTearDown(container.dispose);
container.listen(routePlannerProvider(null), (_, _) {});
final notifier = container.read(routePlannerProvider(null).notifier);
// Try adding NaN or 0,0 stop
notifier.addStop(stop: const RouteStop(id: 'bad', displayName: 'Bad Stop', latitude: 0.0, longitude: 0.0, order: 0, type: RouteStopType.waypoint));
expect(container.read(routePlannerProvider(null)).stops, isEmpty);
expect(container.read(routePlannerProvider(null)).stops, isEmpty);
});
});
group('RFR-1: Widget & Theme Tests', () {
testWidgets('Renderer receives road geometry polyline when route exists', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 892)); // Pixel 8a portrait
final fake = FakeRouteProvider();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
isProSubscriberProvider.overrideWith((ref) => false),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: const MaterialApp(
home: RoutePlannerScreen(),
),
),
);
await tester.pumpAndSettle();
// Initially no polylines rendered
final initialMap = tester.widget<UnifiedMapView>(find.byKey(const ValueKey('route_planner_map_compact')));
expect(initialMap.polylines, isEmpty);
// Add two stops
await tester.tap(find.text('ADD STOP'));
await tester.pumpAndSettle();
final textFields = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField));
await tester.enterText(textFields.at(0), 'Start Point');
await tester.enterText(textFields.at(1), '37.7749');
await tester.enterText(textFields.at(2), '-122.4194');
await tester.tap(find.text('ADD'));
await tester.pumpAndSettle();
// Add second stop
await tester.tap(find.text('ADD STOP'));
await tester.pumpAndSettle();
final textFields2 = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField));
await tester.enterText(textFields2.at(0), 'End Point');
await tester.enterText(textFields2.at(1), '37.3382');
await tester.enterText(textFields2.at(2), '-121.8863');
await tester.tap(find.text('ADD'));
await tester.pumpAndSettle();
// Verify road geometry polyline was passed to UnifiedMapView
final updatedMap = tester.widget<UnifiedMapView>(find.byKey(const ValueKey('route_planner_map_compact')));
expect(updatedMap.polylines.length, 1);
expect(updatedMap.polylines.first.points.length, 2);
expect(updatedMap.polylines.first.points.first.latitude, closeTo(37.7749, 0.001));
expect(updatedMap.polylines.first.points.last.latitude, closeTo(37.3382, 0.001));
});
testWidgets('Light mode controls remain visible with proper contrast', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 892));
final fake = FakeRouteProvider();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
isProSubscriberProvider.overrideWith((ref) => false),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: MaterialApp(
theme: ThemeData.light(),
home: const RoutePlannerScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('ROUTE PLANNER'), findsOneWidget);
expect(find.text('SAVE'), findsOneWidget);
expect(find.text('ADD STOP'), findsOneWidget);
expect(find.text('CURRENT GPS'), findsOneWidget);
expect(find.text('No stops added yet.'), findsOneWidget);
// Open Add Stop dialog in light mode
await tester.tap(find.text('ADD STOP'));
await tester.pumpAndSettle();
expect(find.text('Add Route Stop'), findsOneWidget);
expect(find.text('CANCEL'), findsOneWidget);
expect(find.text('ADD'), findsOneWidget);
// Cancel dialog
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('Dark mode controls render properly', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 892));
final fake = FakeRouteProvider();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
isProSubscriberProvider.overrideWith((ref) => false),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: MaterialApp(
theme: ThemeData.dark(),
home: const RoutePlannerScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('ROUTE PLANNER'), findsOneWidget);
expect(find.text('SAVE'), findsOneWidget);
expect(find.text('ADD STOP'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('Pixel 8a portrait has no RenderFlex overflow with multiple stops and route info', (tester) async {
// Pixel 8a exact viewport: 412 x 892 dp
await tester.binding.setSurfaceSize(const Size(412, 892));
final fake = FakeRouteProvider();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
isProSubscriberProvider.overrideWith((ref) => false),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: const MaterialApp(
home: RoutePlannerScreen(),
),
),
);
await tester.pumpAndSettle();
final titleField = find.byType(TextField).first;
await tester.enterText(titleField, 'Pixel 8a Full Test');
await tester.pumpAndSettle();
expect(find.text('Pixel 8a Full Test'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
}