737 lines
25 KiB
Dart
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();
|
|
});
|
|
});
|
|
}
|