350 lines
12 KiB
Dart
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();
|
|
});
|
|
});
|
|
}
|