MotorSignal-App/test/quick_swarm_report_modal_te...

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);
});
}