347 lines
12 KiB
Dart
347 lines
12 KiB
Dart
// ignore_for_file: subtype_of_sealed_class
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
|
import 'package:motorcycle_dash/services/alerts/swarm_road_alert_service.dart';
|
|
import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart';
|
|
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
|
|
|
class _LocalAlerts implements RoadAlertService {
|
|
final reports = <RoadAlert>[];
|
|
@override
|
|
void simulateAlert(RoadAlert alert) => reports.add(alert);
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _Location extends GeolocatorPlatform {
|
|
late final current = Completer<Position>();
|
|
Future<Position?> Function()? lastKnown;
|
|
@override
|
|
Future<Position> getCurrentPosition({LocationSettings? locationSettings}) =>
|
|
current.future;
|
|
@override
|
|
Future<Position?> getLastKnownPosition({
|
|
bool forceLocationManager = false,
|
|
}) async => lastKnown == null ? null : await lastKnown!();
|
|
}
|
|
|
|
// Delay only the Firestore acknowledgement boundary; use the production
|
|
// SwarmRoadAlertService and an actual fake Firestore document underneath.
|
|
class _DelayedFirestore extends FakeFirebaseFirestore {
|
|
final gate = Completer<void>();
|
|
int writes = 0;
|
|
@override
|
|
CollectionReference<Map<String, dynamic>> collection(String path) =>
|
|
_DelayedCollection(super.collection(path), this);
|
|
}
|
|
|
|
class _DelayedCollection implements CollectionReference<Map<String, dynamic>> {
|
|
_DelayedCollection(this.delegate, this.firestore);
|
|
final CollectionReference<Map<String, dynamic>> delegate;
|
|
@override
|
|
final _DelayedFirestore firestore;
|
|
@override
|
|
DocumentReference<Map<String, dynamic>> doc([String? path]) =>
|
|
_DelayedDocument(delegate.doc(path), firestore);
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _DelayedDocument implements DocumentReference<Map<String, dynamic>> {
|
|
_DelayedDocument(this.delegate, this.firestore);
|
|
final DocumentReference<Map<String, dynamic>> delegate;
|
|
@override
|
|
final _DelayedFirestore firestore;
|
|
@override
|
|
Future<void> set(Map<String, dynamic> data, [SetOptions? options]) async {
|
|
firestore.writes++;
|
|
await firestore.gate.future;
|
|
await delegate.set(data, options);
|
|
}
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
Position _position() => Position(
|
|
latitude: 35.3934, longitude: -83.9261, timestamp: DateTime.now(),
|
|
accuracy: 5, heading: 90, headingAccuracy: 1,
|
|
altitude: 0, altitudeAccuracy: 1, speed: 0, speedAccuracy: 1,
|
|
);
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
late _Location location;
|
|
late _LocalAlerts alerts;
|
|
late GeolocatorPlatform originalLocation;
|
|
|
|
setUp(() {
|
|
originalLocation = GeolocatorPlatform.instance;
|
|
location = _Location();
|
|
GeolocatorPlatform.instance = location;
|
|
alerts = _LocalAlerts();
|
|
});
|
|
tearDown(() => GeolocatorPlatform.instance = originalLocation);
|
|
Future<void> open(WidgetTester tester, {SwarmRoadAlertService? service}) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
activeUserGroupRideProvider.overrideWithValue(null),
|
|
swarmRoadAlertServiceProvider.overrideWithValue(
|
|
service ?? SwarmRoadAlertService(),
|
|
),
|
|
roadAlertServiceProvider.overrideWithValue(alerts),
|
|
],
|
|
child: MaterialApp(
|
|
home: Scaffold(
|
|
body: Builder(
|
|
builder: (context) {
|
|
return TextButton(
|
|
onPressed: () => QuickSwarmReportModal.show(context),
|
|
child: const Text('Open report'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Open report'));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
testWidgets('delayed GPS report survives the sheet exit animation', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
await tester.pumpAndSettle();
|
|
// Deliberately resolve after the original implementation disposed its ref.
|
|
location.current.complete(
|
|
Position(
|
|
latitude: 35.3934,
|
|
longitude: -83.9261,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 5,
|
|
heading: 90,
|
|
headingAccuracy: 1,
|
|
altitude: 0,
|
|
altitudeAccuracy: 1,
|
|
speed: 0,
|
|
speedAccuracy: 1,
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.textContaining("Cannot use 'ref' after the widget was disposed"),
|
|
findsNothing,
|
|
);
|
|
expect(alerts.reports, hasLength(1), reason: tester
|
|
.widgetList<Text>(find.byType(Text))
|
|
.map((text) => text.data)
|
|
.join(' | '));
|
|
expect(alerts.reports.single.category, RoadAlertCategory.hazard);
|
|
expect(find.textContaining('Bad state'), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('dismissal while GPS is pending cancels the report', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
await tester.pump();
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
location.current.complete(
|
|
Position(
|
|
latitude: 35.3934,
|
|
longitude: -83.9261,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 5,
|
|
heading: 90,
|
|
headingAccuracy: 1,
|
|
altitude: 0,
|
|
altitudeAccuracy: 1,
|
|
speed: 0,
|
|
speedAccuracy: 1,
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(alerts.reports, isEmpty);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('repeated taps create one report', (tester) async {
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
await tester.tap(find.text('Road Hazard'));
|
|
await tester.pump();
|
|
expect(
|
|
tester
|
|
.widget<ElevatedButton>(
|
|
find.widgetWithText(ElevatedButton, 'Road Hazard'),
|
|
)
|
|
.onPressed,
|
|
isNull,
|
|
);
|
|
location.current.complete(
|
|
Position(
|
|
latitude: 35.3934,
|
|
longitude: -83.9261,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 5,
|
|
heading: 90,
|
|
headingAccuracy: 1,
|
|
altitude: 0,
|
|
altitudeAccuracy: 1,
|
|
speed: 0,
|
|
speedAccuracy: 1,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, hasLength(1));
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('missing location gives feedback without submitting', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
location.current.completeError(const PermissionDeniedException('denied'));
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, isEmpty);
|
|
expect(
|
|
find.text('Cannot report: GPS location unavailable.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('close cancels even before its exit animation finishes', (tester) async {
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
location.current.complete(_position());
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, isEmpty);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async {
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
await tester.pump(const Duration(seconds: 5));
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, isEmpty);
|
|
expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget);
|
|
expect(tester.widget<ElevatedButton>(find.widgetWithText(
|
|
ElevatedButton, 'Road Hazard')).onPressed, isNotNull);
|
|
location.current.complete(_position());
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, isEmpty);
|
|
});
|
|
|
|
testWidgets('last-known timeout gives failure feedback', (tester) async {
|
|
final lastKnown = Completer<Position?>();
|
|
location.lastKnown = () => lastKnown.future;
|
|
await open(tester);
|
|
await tester.tap(find.text('Road Hazard'));
|
|
location.current.completeError(StateError('GPS unavailable'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await tester.pumpAndSettle();
|
|
expect(find.textContaining('Failed to submit report:'), findsOneWidget);
|
|
expect(alerts.reports, isEmpty);
|
|
lastKnown.complete(null);
|
|
await tester.pump();
|
|
});
|
|
|
|
testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async {
|
|
final firestore = FakeFirebaseFirestore();
|
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
|
await tester.tap(find.text('Road Hazard'));
|
|
location.current.complete(_position());
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, hasLength(1));
|
|
expect(find.textContaining('saved to cloud'), findsOneWidget);
|
|
final docs = await firestore.collection('road_alerts').get();
|
|
expect(docs.docs, hasLength(1));
|
|
expect(docs.docs.single.data()['latitude'], 35.3934);
|
|
});
|
|
|
|
testWidgets('rejected cloud write gives local-only failure feedback', (tester) async {
|
|
final firestore = FakeFirebaseFirestore(securityRules: '''
|
|
service cloud.firestore {
|
|
match /databases/{database}/documents {
|
|
match /{document=**} { allow read: if true; allow write: if false; }
|
|
}
|
|
}
|
|
''');
|
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
|
await tester.tap(find.text('Road Hazard'));
|
|
location.current.complete(_position());
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, hasLength(1));
|
|
expect(find.textContaining('cloud delivery failed'), findsOneWidget);
|
|
expect((await firestore.collection('road_alerts').get()).docs, isEmpty);
|
|
});
|
|
|
|
testWidgets('pending acknowledgement returns locally without retrying write', (tester) async {
|
|
final firestore = _DelayedFirestore();
|
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
|
await tester.tap(find.text('Road Hazard'));
|
|
location.current.complete(_position());
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 6));
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, hasLength(1));
|
|
expect(find.textContaining('cloud delivery pending'), findsOneWidget);
|
|
firestore.gate.complete();
|
|
await tester.pumpAndSettle();
|
|
expect(firestore.writes, 1);
|
|
expect(alerts.reports, hasLength(1));
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('removing provider scope during cloud write avoids disposed service', (tester) async {
|
|
final firestore = _DelayedFirestore();
|
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
|
await tester.tap(find.text('Road Hazard'));
|
|
location.current.complete(_position());
|
|
await tester.pump();
|
|
expect(firestore.writes, 1);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
firestore.gate.complete();
|
|
await tester.pumpAndSettle();
|
|
expect(alerts.reports, isEmpty);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('BUG-008: renders cleanly without overflow on 800x360 landscape phone', (tester) async {
|
|
tester.view.physicalSize = const Size(800, 360);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
await open(tester);
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.text('REPORT ROAD INCIDENT'), findsOneWidget);
|
|
expect(find.text('Police'), findsOneWidget);
|
|
expect(find.text('Road Hazard'), findsOneWidget);
|
|
expect(find.text('Weather Hazard'), findsOneWidget);
|
|
});
|
|
}
|
|
|