MotorSignal-App/test/live_broadcast_controller_t...

737 lines
25 KiB
Dart

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
import 'package:motorcycle_dash/services/group_rides/live_broadcast_controller.dart';
class MockGroupRideService extends GroupRideService {
final List<Map<String, dynamic>> publishedPresences = [];
final List<String> clearedPresences = [];
Exception? errorToThrowOnPublish;
@override
Future<void> publishLivePresence({
required String callerUid,
required String groupRideId,
required double latitude,
required double longitude,
double? heading,
double? speedMph,
double? accuracyMeters,
LivePresenceStatus status = LivePresenceStatus.riding,
bool sharingEnabled = true,
}) async {
if (errorToThrowOnPublish != null) {
throw errorToThrowOnPublish!;
}
publishedPresences.add({
'callerUid': callerUid,
'groupRideId': groupRideId,
'latitude': latitude,
'longitude': longitude,
'heading': heading,
'speedMph': speedMph,
'accuracyMeters': accuracyMeters,
'status': status,
'sharingEnabled': sharingEnabled,
});
}
@override
Future<void> clearLivePresence({
required String callerUid,
required String groupRideId,
}) async {
clearedPresences.add('$callerUid:$groupRideId');
}
}
Position _createPosition({
required double latitude,
required double longitude,
double speed = 15.0, // m/s (~33 mph)
double accuracy = 5.0,
double heading = 90.0,
}) {
return Position(
latitude: latitude,
longitude: longitude,
timestamp: DateTime.now(),
accuracy: accuracy,
altitude: 100.0,
altitudeAccuracy: 5.0,
heading: heading,
headingAccuracy: 2.0,
speed: speed,
speedAccuracy: 1.0,
);
}
void main() {
group('LiveBroadcastController 22-Scenario Unit Tests', () {
late MockGroupRideService mockService;
late StreamController<GroupRide?> rideController;
late StreamController<List<GroupRideParticipant>> participantsController;
late StreamController<List<LiveGroupPresence>> presenceController;
late StreamController<Position> positionController;
DateTime simulatedNow = DateTime(2026, 9, 4, 12, 0, 0);
final now = DateTime.now();
final activeRide = GroupRide(
id: 'ride_live_1',
organizerUid: 'alice',
organizerHandle: 'alice_apex',
organizerDisplayName: 'Alice Apex',
title: 'Twisties Run',
status: GroupRideStatus.underway,
scheduledStartAt: now,
departureAt: now,
participantCount: 2,
meetingPoint: const GroupRideLocation(
latitude: 34.5,
longitude: -84.1,
displayName: 'Start',
),
createdAt: now,
updatedAt: now,
);
final acceptedParticipant = GroupRideParticipant(
userId: 'bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob_rider',
riderDisplayName: 'Bob Rider',
createdAt: now,
updatedAt: now,
);
setUp(() {
mockService = MockGroupRideService();
rideController = StreamController<GroupRide?>.broadcast();
participantsController =
StreamController<List<GroupRideParticipant>>.broadcast();
presenceController =
StreamController<List<LiveGroupPresence>>.broadcast();
positionController = StreamController<Position>.broadcast();
simulatedNow = DateTime(2026, 9, 4, 12, 0, 0);
});
tearDown(() {
rideController.close();
participantsController.close();
presenceController.close();
positionController.close();
});
LiveBroadcastController createTestController({
bool initialMasterAllowed = true,
}) {
return LiveBroadcastController(
rideId: 'ride_live_1',
callerUid: 'bob',
service: mockService,
rideStream: rideController.stream,
participantsStream: participantsController.stream,
presenceStream: presenceController.stream,
positionStream: positionController.stream,
initialMasterAllowed: initialMasterAllowed,
now: () => simulatedNow,
);
}
// 1. Dual-gate privacy
test('1. dual-gate privacy: requires both master allowed and per-ride opt-in',
() async {
final controller = createTestController(initialMasterAllowed: false);
expect(controller.state.isMasterAllowed, isFalse);
expect(controller.state.isOptedIn, isFalse);
expect(controller.state.isBroadcasting, isFalse);
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isEligible, isTrue);
expect(controller.state.isBroadcasting, isFalse);
controller.updateMasterAllowed(true);
expect(controller.state.isMasterAllowed, isTrue);
expect(controller.state.isBroadcasting, isFalse);
await controller.setOptIn(true);
expect(controller.state.isOptedIn, isTrue);
expect(controller.state.isBroadcasting, isTrue);
controller.dispose();
});
// 2. Accuracy filtering
test('2. accuracy filtering: ignores fixes with accuracy > 30m', () async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
positionController.add(_createPosition(
latitude: 34.501,
longitude: -84.101,
accuracy: 50.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.lastPosition, isNull);
expect(mockService.publishedPresences, isEmpty);
positionController.add(_createPosition(
latitude: 34.502,
longitude: -84.102,
accuracy: 10.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.lastPosition, isNotNull);
expect(mockService.publishedPresences.length, equals(1));
controller.dispose();
});
// 3. Quick statuses
test('3. quick statuses: setting needFuel or regroup updates and publishes',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 34.505,
longitude: -84.105,
accuracy: 5.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
expect(mockService.publishedPresences.last['status'],
equals(LivePresenceStatus.riding));
await controller.setStatus(LivePresenceStatus.needFuel);
expect(controller.state.status, equals(LivePresenceStatus.needFuel));
expect(mockService.publishedPresences.last['status'],
equals(LivePresenceStatus.needFuel));
controller.dispose();
});
// 4. Authorization loss: ride completion
test('4. authorization loss: ride completion immediately clears presence',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
final completedRide =
activeRide.copyWith(status: GroupRideStatus.completed);
rideController.add(completedRide);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isEligible, isFalse);
expect(controller.state.isBroadcasting, isFalse);
expect(mockService.clearedPresences, contains('bob:ride_live_1'));
controller.dispose();
});
// 5. Authorization loss: participant removal
test('5. authorization loss: participant removal immediately revokes and clears',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
participantsController.add([]);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isEligible, isFalse);
expect(controller.state.isBroadcasting, isFalse);
expect(mockService.clearedPresences, contains('bob:ride_live_1'));
controller.dispose();
});
// 6. Ghost mode: per-ride opt-in toggle false
test('6. ghost mode: toggling opt-in to false clears presence and stops broadcast',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
await controller.setOptIn(false);
expect(controller.state.isOptedIn, isFalse);
expect(controller.state.isBroadcasting, isFalse);
expect(mockService.clearedPresences, contains('bob:ride_live_1'));
controller.dispose();
});
// 7. Master toggle disable
test('7. master toggle disable: clearing master setting stops broadcast and clears presence',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
controller.updateMasterAllowed(false);
expect(controller.state.isMasterAllowed, isFalse);
expect(controller.state.isBroadcasting, isFalse);
expect(mockService.clearedPresences, contains('bob:ride_live_1'));
controller.dispose();
});
// 8. Stopped cadence: stationary fixes publish at 30s intervals
test('8. stopped cadence: stationary fixes publish at 30s intervals',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
// Initial stopped fix (speed = 0)
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 0.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 10 seconds later: stationary -> should NOT publish
simulatedNow = simulatedNow.add(const Duration(seconds: 10));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 0.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 29 seconds later -> should NOT publish
simulatedNow = DateTime(2026, 9, 4, 12, 0, 29);
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 0.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 30 seconds later -> publishes 2nd fix
simulatedNow = DateTime(2026, 9, 4, 12, 0, 30);
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 0.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(2));
controller.dispose();
});
// 9. Moving cadence: movement (>3 mph, displacement > 15m) publishes at 5s intervals
test('9. moving cadence: movement (>3 mph, displacement > 15m) publishes at 5s intervals',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
// Initial fix at 15 m/s (~33 mph)
positionController.add(_createPosition(
latitude: 34.5000,
longitude: -84.1000,
speed: 15.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 4 seconds later with 50m displacement -> elapsed < 5s -> suppressed
simulatedNow = simulatedNow.add(const Duration(seconds: 4));
positionController.add(_createPosition(
latitude: 34.5005,
longitude: -84.1000,
speed: 15.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 5 seconds elapsed (total) with 50m displacement -> publishes 2nd fix
simulatedNow = DateTime(2026, 9, 4, 12, 0, 5);
positionController.add(_createPosition(
latitude: 34.5005,
longitude: -84.1000,
speed: 15.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(2));
controller.dispose();
});
// 10. Displacement threshold: stationary jitter (<15m) suppressed
test('10. displacement threshold: stationary jitter (<15m) suppressed',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
// Initial fix
positionController.add(_createPosition(
latitude: 34.500000,
longitude: -84.100000,
speed: 10.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 6 seconds elapsed, but position jittered only ~2 meters (<15m)
simulatedNow = simulatedNow.add(const Duration(seconds: 6));
positionController.add(_createPosition(
latitude: 34.500020,
longitude: -84.100000,
speed: 10.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
// Suppressed because displacement < 15m and elapsed < 10s
expect(mockService.publishedPresences.length, equals(1));
controller.dispose();
});
// 11. Invalid coordinates: NaN or out of range rejected
test('11. invalid coordinates: lat/long NaN or out-of-range rejected',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: double.nan,
longitude: -84.100,
));
positionController.add(_createPosition(
latitude: 95.0,
longitude: -84.100,
));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -190.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.lastPosition, isNull);
expect(mockService.publishedPresences, isEmpty);
controller.dispose();
});
// 12. Null Island (0,0) rejected
test('12. (0,0) Null Island coordinates rejected', () async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 0.0,
longitude: 0.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.lastPosition, isNull);
expect(mockService.publishedPresences, isEmpty);
controller.dispose();
});
// 13. Transient network error
test('13. transient network error: enters paused/reconnecting, backs off, does not crash',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
mockService.errorToThrowOnPublish =
Exception('SocketException: Failed host lookup');
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isPaused, isTrue);
expect(controller.state.retryCount, equals(1));
expect(controller.state.isBroadcasting, isTrue); // Still opted-in and eligible
controller.dispose();
});
// 14. Bounded exponential backoff
test('14. backoff exponential: retries with bounded backoff (5s to 30s)',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
mockService.errorToThrowOnPublish = Exception('503 Service Unavailable');
// Attempt 1: backoff is 5s
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.retryCount, equals(1));
// Attempt at +2s (should be ignored by backoff gate)
simulatedNow = simulatedNow.add(const Duration(seconds: 2));
positionController.add(_createPosition(
latitude: 34.501,
longitude: -84.101,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.retryCount, equals(1));
// Attempt at +5s: backoff satisfied, attempt fires and fails -> retryCount = 2, next backoff 10s
simulatedNow = DateTime(2026, 9, 4, 12, 0, 5);
positionController.add(_createPosition(
latitude: 34.502,
longitude: -84.102,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.retryCount, equals(2));
controller.dispose();
});
// 15. Terminal auth error (permission-denied)
test('15. terminal auth error (permission-denied): aborts, revokes broadcast, no retry storm',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
mockService.errorToThrowOnPublish =
Exception('[cloud_firestore/permission-denied] Missing or insufficient permissions');
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isFalse);
expect(controller.state.isOptedIn, isFalse);
expect(controller.state.isEligible, isFalse);
expect(mockService.clearedPresences, contains('bob:ride_live_1'));
controller.dispose();
});
// 16. Terminal auth error (unauthenticated)
test('16. terminal auth error (unauthenticated): aborts, cancels timers',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
mockService.errorToThrowOnPublish =
Exception('[cloud_firestore/unauthenticated] Request not authenticated');
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isFalse);
expect(controller.state.isOptedIn, isFalse);
expect(controller.state.isEligible, isFalse);
controller.dispose();
});
// 17. App lifecycle: paused
test('17. app lifecycle: paused state transitions properly', () {
final controller = createTestController();
expect(controller.state.isBackground, isFalse);
controller.updateAppLifecycle(AppLifecycleState.paused);
expect(controller.state.isBackground, isTrue);
controller.dispose();
});
// 18. App lifecycle: resumed
test('18. app lifecycle: resumed state reconnects / re-evaluates', () {
final controller = createTestController();
controller.updateAppLifecycle(AppLifecycleState.paused);
expect(controller.state.isBackground, isTrue);
controller.updateAppLifecycle(AppLifecycleState.resumed);
expect(controller.state.isBackground, isFalse);
controller.dispose();
});
// 19. Regroup activation
test('19. regroup activation sets status regrouping and broadcasts',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
await controller.triggerRegroup();
expect(controller.state.status, equals(LivePresenceStatus.regrouping));
expect(mockService.publishedPresences.last['status'],
equals(LivePresenceStatus.regrouping));
controller.dispose();
});
// 20. Regroup clearing
test('20. regroup clearing resets to riding', () async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
await controller.triggerRegroup();
expect(controller.state.status, equals(LivePresenceStatus.regrouping));
await controller.clearRegroup();
expect(controller.state.status, equals(LivePresenceStatus.riding));
expect(mockService.publishedPresences.last['status'],
equals(LivePresenceStatus.riding));
controller.dispose();
});
// 21. Stream cleanup: dispose cancels all subscriptions and clears presence
test('21. stream cleanup: dispose cancels all subscriptions and timers',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
controller.dispose();
expect(mockService.clearedPresences, contains('bob:ride_live_1'));
// Adding to streams after dispose should not affect controller or cause crash
rideController.add(null);
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences, isEmpty);
});
// 22. Duplicate suppression within cadence window
test('22. duplicate suppression: identical payload within cadence window not sent',
() async {
final controller = createTestController();
rideController.add(activeRide);
participantsController.add([acceptedParticipant]);
await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10));
// Publish initial fix
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 15.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(mockService.publishedPresences.length, equals(1));
// 1 second later: emit exact same coordinate
simulatedNow = simulatedNow.add(const Duration(seconds: 1));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 15.0,
));
await Future<void>.delayed(const Duration(milliseconds: 10));
// Suppressed! Length remains 1
expect(mockService.publishedPresences.length, equals(1));
controller.dispose();
});
});
}