397 lines
16 KiB
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);
|
|
});
|
|
});
|
|
}
|