import 'dart:async'; 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 = []; @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 { 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 Unit Tests', () { late MockGroupRideService mockService; late StreamController rideController; late StreamController> participantsController; late StreamController> presenceController; late StreamController positionController; 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(); }); tearDown(() { rideController.close(); participantsController.close(); presenceController.close(); positionController.close(); }); test('dual-gate privacy: requires both master allowed and per-ride opt-in', () async { final controller = LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, rideStream: rideController.stream, participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, initialMasterAllowed: false, ); // Initially neither is enabled expect(controller.state.isMasterAllowed, isFalse); expect(controller.state.isOptedIn, isFalse); expect(controller.state.isBroadcasting, isFalse); // Emit active ride and accepted membership rideController.add(activeRide); participantsController.add([acceptedParticipant]); await Future.delayed(const Duration(milliseconds: 10)); expect(controller.state.isEligible, isTrue); expect(controller.state.isBroadcasting, isFalse); // Enable master allowed only controller.updateMasterAllowed(true); expect(controller.state.isMasterAllowed, isTrue); expect(controller.state.isBroadcasting, isFalse); // Still false because optIn is false // Enable optIn await controller.setOptIn(true); expect(controller.state.isOptedIn, isTrue); expect(controller.state.isBroadcasting, isTrue); controller.dispose(); }); test('accuracy filtering: ignores fixes with accuracy > 30m', () async { final controller = LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, rideStream: rideController.stream, participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, initialMasterAllowed: true, ); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); await Future.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); // Emit fix with 50m accuracy (should be rejected) 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); // Emit fix with 10m accuracy (should be accepted) 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)); expect(mockService.publishedPresences.first['latitude'], equals(34.502)); controller.dispose(); }); test('quick statuses: setting needFuel or regroup updates and publishes', () async { final controller = LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, rideStream: rideController.stream, participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, initialMasterAllowed: true, ); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); await Future.delayed(const Duration(milliseconds: 10)); // Emit valid initial position 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)); // Select NEED FUEL await controller.setStatus(LivePresenceStatus.needFuel); expect(controller.state.status, equals(LivePresenceStatus.needFuel)); expect(mockService.publishedPresences.last['status'], equals(LivePresenceStatus.needFuel)); // Trigger REGROUP await controller.triggerRegroup(); expect(controller.state.status, equals(LivePresenceStatus.regrouping)); expect(mockService.publishedPresences.last['status'], equals(LivePresenceStatus.regrouping)); // Clear REGROUP await controller.clearRegroup(); expect(controller.state.status, equals(LivePresenceStatus.riding)); expect(mockService.publishedPresences.last['status'], equals(LivePresenceStatus.riding)); controller.dispose(); }); test('authorization loss: ride completion immediately clears presence', () async { final controller = LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, rideStream: rideController.stream, participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, initialMasterAllowed: true, ); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); await Future.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); // Ride concludes 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(); }); test('authorization loss: participant removal immediately revokes and clears', () async { final controller = LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, rideStream: rideController.stream, participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, initialMasterAllowed: true, ); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); await Future.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); // Organizer removes Bob from participants 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(); }); test('ghost mode: toggling opt-in to false clears presence and stops broadcast', () async { final controller = LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, rideStream: rideController.stream, participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, initialMasterAllowed: true, ); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); await Future.delayed(const Duration(milliseconds: 10)); expect(controller.state.isBroadcasting, isTrue); // Toggle off opt-in await controller.setOptIn(false); expect(controller.state.isOptedIn, isFalse); expect(controller.state.isBroadcasting, isFalse); expect(mockService.clearedPresences, contains('bob:ride_live_1')); controller.dispose(); }); }); }