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> publishedPresences = []; final List clearedPresences = []; Exception? errorToThrowOnPublish; @override Future 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 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 rideController; late StreamController> participantsController; late StreamController> presenceController; late StreamController 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.broadcast(); participantsController = StreamController>.broadcast(); presenceController = StreamController>.broadcast(); positionController = StreamController.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.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.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); positionController.add(_createPosition( latitude: 34.501, longitude: -84.101, accuracy: 50.0, )); await Future.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.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.delayed(const Duration(milliseconds: 10)); positionController.add(_createPosition( latitude: 34.505, longitude: -84.105, accuracy: 5.0, )); await Future.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.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); final completedRide = activeRide.copyWith(status: GroupRideStatus.completed); rideController.add(completedRide); await Future.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.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); participantsController.add([]); await Future.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.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.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.delayed(const Duration(milliseconds: 10)); // Initial stopped fix (speed = 0) positionController.add(_createPosition( latitude: 34.500, longitude: -84.100, speed: 0.0, )); await Future.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.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.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.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.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.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.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.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.delayed(const Duration(milliseconds: 10)); // Initial fix positionController.add(_createPosition( latitude: 34.500000, longitude: -84.100000, speed: 10.0, )); await Future.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.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.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.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.delayed(const Duration(milliseconds: 10)); positionController.add(_createPosition( latitude: 0.0, longitude: 0.0, )); await Future.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.delayed(const Duration(milliseconds: 10)); mockService.errorToThrowOnPublish = Exception('SocketException: Failed host lookup'); positionController.add(_createPosition( latitude: 34.500, longitude: -84.100, )); await Future.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.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.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.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.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.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.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.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.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.delayed(const Duration(milliseconds: 10)); positionController.add(_createPosition( latitude: 34.500, longitude: -84.100, )); await Future.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.delayed(const Duration(milliseconds: 10)); positionController.add(_createPosition( latitude: 34.500, longitude: -84.100, )); await Future.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.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.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.delayed(const Duration(milliseconds: 10)); // Publish initial fix positionController.add(_createPosition( latitude: 34.500, longitude: -84.100, speed: 15.0, )); await Future.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.delayed(const Duration(milliseconds: 10)); // Suppressed! Length remains 1 expect(mockService.publishedPresences.length, equals(1)); controller.dispose(); }); }); }