MotorSignal-App/test/live_broadcast_controller_t...

350 lines
12 KiB
Dart

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<Map<String, dynamic>> publishedPresences = [];
final List<String> clearedPresences = [];
@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 {
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 Unit Tests', () {
late MockGroupRideService mockService;
late StreamController<GroupRide?> rideController;
late StreamController<List<GroupRideParticipant>> participantsController;
late StreamController<List<LiveGroupPresence>> presenceController;
late StreamController<Position> 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<GroupRide?>.broadcast();
participantsController =
StreamController<List<GroupRideParticipant>>.broadcast();
presenceController =
StreamController<List<LiveGroupPresence>>.broadcast();
positionController = StreamController<Position>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 10));
// Emit valid initial position
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));
// 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<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
// Ride concludes
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();
});
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<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
// Organizer removes Bob from participants
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();
});
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<void>.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();
});
});
}