MotorSignal-App/test/group_ride_service_test.dart

520 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/app_colors.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
void main() {
group('GroupRideService Offline & Validation Tests', () {
final organizer = RiderProfile(
uid: 'user_alice',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
test('rejects ride creation with invalid title', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Meetup',
);
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Hi', // too short (< 3)
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 30)),
meetingPoint: meetup,
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects departure before arrival time', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Meetup',
);
final now = DateTime.now();
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Morning Cruise',
scheduledStartAt: now.add(const Duration(hours: 2)),
departureAt: now.add(const Duration(hours: 1)), // earlier!
meetingPoint: meetup,
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects participant limit less than 2', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Meetup',
);
final now = DateTime.now();
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Solo Ride',
scheduledStartAt: now,
departureAt: now.add(const Duration(minutes: 30)),
participantLimit: 1, // Invalid!
meetingPoint: meetup,
),
throwsA(isA<ArgumentError>()),
);
});
test('creates offline fallback ride safely when unconfigured', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.5,
longitude: -83.9,
displayName: 'Dahlonega',
);
final start = DateTime.now().add(const Duration(days: 1));
final dep = start.add(const Duration(minutes: 30));
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Sunday Morning Run',
description: 'Twisty mountain roads',
visibility: GroupRideVisibility.friendsOnly,
scheduledStartAt: start,
departureAt: dep,
meetingPoint: meetup,
);
expect(ride.title, equals('Sunday Morning Run'));
expect(ride.organizerUid, equals('user_alice'));
expect(ride.meetingPoint.displayName, equals('Dahlonega'));
expect(ride.participantCount, equals(1));
});
test('cannot invite self to group ride', () async {
final service = GroupRideService();
expect(
() => service.inviteFriend(
callerUid: 'user_alice',
groupRideId: 'ride_1',
friendProfile: organizer, // same UID!
),
throwsA(isA<ArgumentError>()),
);
});
test('publishLivePresence validates physical coordinates', () async {
final service = GroupRideService();
expect(
() => service.publishLivePresence(
callerUid: 'user_alice',
groupRideId: 'ride_1',
latitude: 105.0, // out of range
longitude: -84.0,
),
throwsA(isA<ArgumentError>()),
);
});
test('offline live presence and upcoming streams return safely', () async {
final service = GroupRideService();
final stream = service.livePresenceDocStream(groupRideId: 'ride_offline', targetUid: 'user_alice');
expect(await stream.first, isNull);
final pStream = service.participantDocStream(groupRideId: 'ride_offline', participantUid: 'user_alice');
expect(await pStream.first, isNull);
final upcoming = service.upcomingRidesStream(callerUid: 'user_alice');
expect(await upcoming.first, isEmpty);
});
});
group('Live Presence Point Listener Lifecycle Tests', () {
test('reconciles candidates from accessUids and only subscribes to accepted participants', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_123',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Sunday Morning Twisties',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_accepted', 'user_invited', 'user_declined'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_123').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<LiveGroupPresence>>[];
container.listen(
groupRideLivePresenceProvider('ride_123'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_accepted').add(
GroupRideParticipant(
userId: 'user_accepted',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_invited').add(
GroupRideParticipant(
userId: 'user_invited',
role: GroupRideRole.participant,
status: ParticipantStatus.invited,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_declined').add(
GroupRideParticipant(
userId: 'user_declined',
role: GroupRideRole.participant,
status: ParticipantStatus.declined,
riderHandle: 'charlie',
riderDisplayName: 'Charlie',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
final presenceAlice = LiveGroupPresence(
userId: 'user_accepted',
groupRideId: 'ride_123',
latitude: 37.7749,
longitude: -122.4194,
speedMph: 45.0,
sharingEnabled: true,
updatedAt: DateTime.now(),
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
);
fakeService.getPresenceController('user_accepted').add(presenceAlice);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue);
expect(states.last.any((p) => p.userId == 'user_invited'), isFalse);
expect(states.last.any((p) => p.userId == 'user_declined'), isFalse);
});
test('peer error isolation: peer permission-denied does not drop other peer presence', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_error_isolation',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Ride',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_alice', 'user_bob'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_error_isolation').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<LiveGroupPresence>>[];
container.listen(
groupRideLivePresenceProvider('ride_error_isolation'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
userId: 'user_alice',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_bob').add(
GroupRideParticipant(
userId: 'user_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
final presenceAlice = LiveGroupPresence(
userId: 'user_alice',
groupRideId: 'ride_error_isolation',
latitude: 37.7749,
longitude: -122.4194,
speedMph: 45.0,
sharingEnabled: true,
updatedAt: DateTime.now(),
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
);
fakeService.getPresenceController('user_alice').add(presenceAlice);
fakeService.getPresenceController('user_bob').addError('permission-denied');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 1);
expect(states.last.first.userId, 'user_alice');
});
test('participants provider reconciles candidates from accessUids and callerUid without broad collection queries', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_participants_test',
organizerUid: 'user_alice',
organizerHandle: 'alice',
organizerDisplayName: 'Alice',
title: 'Ride',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_alice', 'user_bob'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_participants_test').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<GroupRideParticipant>>[];
container.listen(
groupRideParticipantsProvider('ride_participants_test'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
userId: 'user_alice',
role: GroupRideRole.organizer,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_bob').add(
GroupRideParticipant(
userId: 'user_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 2);
expect(states.last.first.userId, 'user_alice');
expect(states.last.last.userId, 'user_bob');
});
});
group('UI Layout and Styling Regression Tests', () {
testWidgets('Live presence header does not overflow on 393px width constraint', (tester) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.darkTheme,
home: Scaffold(
body: Center(
child: SizedBox(
width: 393,
child: Row(
children: [
const Icon(Icons.sensors, color: AppColors.brandBlue, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'LIVE RIDE PRESENCE',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.white,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.brandBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.brandBlue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
const Text(
'BROADCASTING',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.brandBlue,
),
),
],
),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('LIVE RIDE PRESENCE'), findsOneWidget);
expect(find.text('BROADCASTING'), findsOneWidget);
});
test('Light theme AppBar has dark status bar icons and high contrast title', () {
final lightTheme = AppTheme.lightTheme;
expect(lightTheme.appBarTheme.titleTextStyle?.color, AppColors.lightOnSurface);
expect(lightTheme.appBarTheme.iconTheme?.color, AppColors.lightOnSurface);
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
});
});
}
class _FakeGroupRideLifecycleService implements GroupRideService {
final Map<String, StreamController<GroupRideParticipant?>> participantControllers = {};
final Map<String, StreamController<LiveGroupPresence?>> presenceControllers = {};
StreamController<GroupRideParticipant?> getParticipantController(String uid) {
return participantControllers.putIfAbsent(
uid,
() => StreamController<GroupRideParticipant?>.broadcast(),
);
}
StreamController<LiveGroupPresence?> getPresenceController(String uid) {
return presenceControllers.putIfAbsent(
uid,
() => StreamController<LiveGroupPresence?>.broadcast(),
);
}
@override
Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) {
return getParticipantController(participantUid).stream;
}
@override
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) {
return getPresenceController(targetUid).stream;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}