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()), ); }); 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()), ); }); 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()), ); }); 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()), ); }); 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()), ); }); 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.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 = >[]; container.listen( groupRideLivePresenceProvider('ride_123'), (previous, next) { next.whenData((data) => states.add(data)); }, ); rideStreamController.add(rideDoc); await Future.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.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.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.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 = >[]; container.listen( groupRideLivePresenceProvider('ride_error_isolation'), (previous, next) { next.whenData((data) => states.add(data)); }, ); rideStreamController.add(rideDoc); await Future.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.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.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.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 = >[]; container.listen( groupRideParticipantsProvider('ride_participants_test'), (previous, next) { next.whenData((data) => states.add(data)); }, ); rideStreamController.add(rideDoc); await Future.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.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> participantControllers = {}; final Map> presenceControllers = {}; StreamController getParticipantController(String uid) { return participantControllers.putIfAbsent( uid, () => StreamController.broadcast(), ); } StreamController getPresenceController(String uid) { return presenceControllers.putIfAbsent( uid, () => StreamController.broadcast(), ); } @override Stream participantDocStream({ required String groupRideId, required String participantUid, }) { return getParticipantController(participantUid).stream; } @override Stream livePresenceDocStream({ required String groupRideId, required String targetUid, }) { return getPresenceController(targetUid).stream; } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }