import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; void main() { group('GroupRideLocation Model Tests', () { test('serializes and deserializes meeting location correctly', () { const loc = GroupRideLocation( latitude: 34.5378, longitude: -83.9849, displayName: 'Dahlonega Square', address: '123 Main St, Dahlonega, GA', locationType: GroupRideLocationType.meetup, notes: 'Meet near north corner', stopDurationMinutes: 15, ); final json = loc.toJson(); expect(json['latitude'], equals(34.5378)); expect(json['longitude'], equals(-83.9849)); expect(json['displayName'], equals('Dahlonega Square')); expect(json['locationType'], equals('meetup')); expect(json['stopDurationMinutes'], equals(15)); final restored = GroupRideLocation.fromJson(json); expect(restored, equals(loc)); }); test('supports intermediate stops and location types', () { expect(GroupRideLocationType.fuel.label, equals('Fuel Stop')); expect(GroupRideLocationType.scenic.label, equals('Scenic Overlook')); expect(GroupRideLocationType.food.label, equals('Food & Drink')); }); }); group('GroupRide Model Tests', () { test('serializes and deserializes full group ride', () { final now = DateTime(2026, 9, 3, 10, 0); final start = DateTime(2026, 9, 10, 9, 0); final departure = DateTime(2026, 9, 10, 9, 30); final ride = GroupRide( id: 'ride_101', organizerUid: 'alice', organizerHandle: 'alice_apex', organizerDisplayName: 'Alice Apex', title: 'Blood Mountain Run', description: 'Twisty mountain loop.', visibility: GroupRideVisibility.friendsOnly, joinPolicy: GroupRideJoinPolicy.inviteOnly, status: GroupRideStatus.scheduled, scheduledStartAt: start, departureAt: departure, participantLimit: 8, participantCount: 3, meetingPoint: const GroupRideLocation( latitude: 34.5, longitude: -83.9, displayName: 'Meetup Chevron', locationType: GroupRideLocationType.meetup, ), destination: const GroupRideLocation( latitude: 34.9, longitude: -83.8, displayName: 'Brasstown Bald', locationType: GroupRideLocationType.destination, ), stops: const [ GroupRideLocation( latitude: 34.7, longitude: -83.85, displayName: 'Vogel State Park', locationType: GroupRideLocationType.rest, ), ], createdAt: now, updatedAt: now, ); final json = ride.toJson(); expect(json['id'], equals('ride_101')); expect(json['organizerUid'], equals('alice')); expect(json['title'], equals('Blood Mountain Run')); expect(json['visibility'], equals('friendsOnly')); expect(json['participantLimit'], equals(8)); expect(json['participantCount'], equals(3)); expect((json['stops'] as List).length, equals(1)); final restored = GroupRide.fromJson(json); expect(restored.id, equals(ride.id)); expect(restored.organizerUid, equals(ride.organizerUid)); expect(restored.title, equals(ride.title)); expect(restored.visibility, equals(GroupRideVisibility.friendsOnly)); expect(restored.isFull, isFalse); expect(restored.isCanceled, isFalse); expect(restored.stops.length, equals(1)); expect(restored.stops.first.displayName, equals('Vogel State Park')); }); test('participant capacity check respects limit', () { final now = DateTime.now(); final ride = GroupRide( id: 'ride_full', organizerUid: 'alice', organizerHandle: 'alice_apex', organizerDisplayName: 'Alice', title: 'Tight Crew', scheduledStartAt: now, departureAt: now, participantLimit: 2, participantCount: 2, meetingPoint: const GroupRideLocation( latitude: 0, longitude: 0, displayName: 'Base', ), createdAt: now, updatedAt: now, ); expect(ride.isFull, isTrue); }); }); group('GroupRide clubId Domain Tests', () { final now = DateTime(2026, 9, 20, 10, 0); final baseRide = GroupRide( id: 'ride_base', organizerUid: 'organizer_1', organizerHandle: 'organizer', organizerDisplayName: 'Organizer', title: 'Sunday Morning Run', scheduledStartAt: now, departureAt: now.add(const Duration(minutes: 30)), meetingPoint: const GroupRideLocation( latitude: 34.5, longitude: -84.0, displayName: 'Base Meetup', ), createdAt: now, updatedAt: now, ); test('1. missing clubId deserializes to null', () { final json = baseRide.toJson(); expect(json.containsKey('clubId'), isFalse); final restored = GroupRide.fromJson(json); expect(restored.clubId, isNull); }); test('2. explicit null clubId deserializes to null', () { final json = baseRide.toJson(); json['clubId'] = null; final restored = GroupRide.fromJson(json); expect(restored.clubId, isNull); }); test('3. non-null clubId round-trips exactly', () { final clubRide = GroupRide( id: 'ride_club_1', organizerUid: 'organizer_1', organizerHandle: 'organizer', organizerDisplayName: 'Organizer', title: 'Club Mountain Loop', scheduledStartAt: now, departureAt: now.add(const Duration(minutes: 30)), meetingPoint: const GroupRideLocation( latitude: 34.5, longitude: -84.0, displayName: 'Club Meetup', ), createdAt: now, updatedAt: now, clubId: 'club_north_georgia_riders', ); final json = clubRide.toJson(); expect(json['clubId'], equals('club_north_georgia_riders')); final restored = GroupRide.fromJson(json); expect(restored.clubId, equals('club_north_georgia_riders')); expect(restored, equals(clubRide)); }); test('4. null clubId serialization preserves legacy document shape by omitting clubId', () { expect(baseRide.clubId, isNull); final json = baseRide.toJson(); expect(json.containsKey('clubId'), isFalse); }); test('5. blank or whitespace-only clubId is rejected at domain boundary', () { // Direct constructor validation expect( () => GroupRide( id: 'r1', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: '', ), throwsA(isA()), ); expect( () => GroupRide( id: 'r2', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: ' ', ), throwsA(isA()), ); // Internal, leading, or trailing whitespace rejected expect( () => GroupRide( id: 'r3', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: 'club 123', ), throwsA(isA()), ); expect( () => GroupRide( id: 'r4', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: ' club_123', ), throwsA(isA()), ); // Exceeds 64 characters rejected expect( () => GroupRide( id: 'r5', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: 'c' * 65, ), throwsA(isA()), ); // Deserialization validation final jsonEmpty = baseRide.toJson()..['clubId'] = ''; expect( () => GroupRide.fromJson(jsonEmpty), throwsA(isA()), ); final jsonWhitespace = baseRide.toJson()..['clubId'] = ' \t\n '; expect( () => GroupRide.fromJson(jsonWhitespace), throwsA(isA()), ); final jsonInternalSpace = baseRide.toJson()..['clubId'] = 'club alpha'; expect( () => GroupRide.fromJson(jsonInternalSpace), throwsA(isA()), ); final jsonTooLong = baseRide.toJson()..['clubId'] = 'c' * 65; expect( () => GroupRide.fromJson(jsonTooLong), throwsA(isA()), ); }); test('7. copyWith and object reconstruction preserves clubId affiliation immutably', () { final clubRide = GroupRide( id: 'ride_club_1', organizerUid: 'organizer_1', organizerHandle: 'organizer', organizerDisplayName: 'Organizer', title: 'Club Mountain Loop', scheduledStartAt: now, departureAt: now.add(const Duration(minutes: 30)), meetingPoint: const GroupRideLocation( latitude: 34.5, longitude: -84.0, displayName: 'Club Meetup', ), createdAt: now, updatedAt: now, clubId: 'club_north_georgia_riders', ); final updated = clubRide.copyWith(title: 'Updated Loop Title', status: GroupRideStatus.underway); expect(updated.clubId, equals('club_north_georgia_riders')); expect(updated.title, equals('Updated Loop Title')); expect(updated.status, equals(GroupRideStatus.underway)); // Personal ride copyWith retains null clubId final personalCopy = baseRide.copyWith(title: 'Updated Personal'); expect(personalCopy.clubId, isNull); }); test('8. equality and hashCode include clubId', () { final ridePersonal1 = GroupRide( id: 'r1', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: null, ); final ridePersonal2 = GroupRide( id: 'r1', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: null, ); final rideClubA = GroupRide( id: 'r1', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: 'club_alpha', ); final rideClubA2 = GroupRide( id: 'r1', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: 'club_alpha', ); final rideClubB = GroupRide( id: 'r1', organizerUid: 'u1', organizerHandle: 'h1', organizerDisplayName: 'd1', title: 'Ride', scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, clubId: 'club_bravo', ); expect(ridePersonal1, equals(ridePersonal2)); expect(ridePersonal1.hashCode, equals(ridePersonal2.hashCode)); expect(rideClubA, equals(rideClubA2)); expect(rideClubA.hashCode, equals(rideClubA2.hashCode)); // Personal vs Club are not equal expect(ridePersonal1 == rideClubA, isFalse); expect(ridePersonal1.hashCode == rideClubA.hashCode, isFalse); // Club A vs Club B are not equal expect(rideClubA == rideClubB, isFalse); expect(rideClubA.hashCode == rideClubB.hashCode, isFalse); }); }); group('GroupRideParticipant Model Tests', () { test('serializes and deserializes participant', () { final now = DateTime(2026, 9, 3, 12, 0); final participant = GroupRideParticipant( userId: 'bob', role: GroupRideRole.participant, status: ParticipantStatus.accepted, riderHandle: 'bob_rider', riderDisplayName: 'Bob Rider', riderAvatarPreset: 'helmet_amber', invitedBy: 'alice', createdAt: now, updatedAt: now, ); final json = participant.toJson(); expect(json['userId'], equals('bob')); expect(json['role'], equals('participant')); expect(json['status'], equals('accepted')); expect(json['riderHandle'], equals('bob_rider')); expect(json['invitedBy'], equals('alice')); final restored = GroupRideParticipant.fromJson(json); expect(restored.userId, equals('bob')); expect(restored.status.isActiveMember, isTrue); expect(restored.displayHandle, equals('@bob_rider')); }); }); group('LiveGroupPresence and Ride Lifecycle Tests', () { test('verifies GroupRideStatus underway and isLiveEligible semantics', () { expect(GroupRideStatus.underway.label, equals('Underway')); final now = DateTime.now(); final rideScheduled = GroupRide( id: 'r1', organizerUid: 'alice', organizerHandle: 'alice', organizerDisplayName: 'Alice', title: 'Run', status: GroupRideStatus.scheduled, scheduledStartAt: now, departureAt: now, meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), createdAt: now, updatedAt: now, ); expect(rideScheduled.isLiveEligible, isTrue); expect(rideScheduled.isUnderway, isFalse); final rideUnderway = rideScheduled.copyWith(status: GroupRideStatus.underway); expect(rideUnderway.isUnderway, isTrue); expect(rideUnderway.isLiveEligible, isTrue); final rideCompleted = rideScheduled.copyWith(status: GroupRideStatus.completed); expect(rideCompleted.isLiveEligible, isFalse); expect(rideCompleted.isUnderway, isFalse); final rideCanceled = rideScheduled.copyWith(status: GroupRideStatus.canceled); expect(rideCanceled.isLiveEligible, isFalse); }); test('serializes and deserializes LiveGroupPresence correctly', () { final now = DateTime.now(); final expires = now.add(const Duration(seconds: 120)); final presence = LiveGroupPresence( userId: 'bob', groupRideId: 'ride_101', latitude: 34.5378, longitude: -83.9849, heading: 180.5, speedMph: 48.2, accuracyMeters: 4.5, updatedAt: now, expiresAt: expires, sharingEnabled: true, status: LivePresenceStatus.riding, ); final json = presence.toJson(); expect(json['userId'], equals('bob')); expect(json['groupRideId'], equals('ride_101')); expect(json['latitude'], equals(34.5378)); expect(json['longitude'], equals(-83.9849)); expect(json['heading'], equals(180.5)); expect(json['speedMph'], equals(48.2)); expect(json['accuracyMeters'], equals(4.5)); expect(json['sharingEnabled'], isTrue); expect(json['status'], equals('riding')); final restored = LiveGroupPresence.fromJson(json); expect(restored.userId, equals('bob')); expect(restored.groupRideId, equals('ride_101')); expect(restored.latitude, equals(34.5378)); expect(restored.longitude, equals(-83.9849)); expect(restored.status, equals(LivePresenceStatus.riding)); expect(restored.isValidCoordinates, isTrue); expect(restored.isAccurate, isTrue); expect(restored.isFresh, isTrue); expect(restored.isStale, isFalse); }); test('coordinate bounds validation rejects invalid coordinates', () { final now = DateTime.now(); final invalidLat = LiveGroupPresence( userId: 'bob', groupRideId: 'r1', latitude: 91.0, longitude: 0.0, updatedAt: now, expiresAt: now.add(const Duration(seconds: 120)), ); expect(invalidLat.isValidCoordinates, isFalse); final invalidLng = LiveGroupPresence( userId: 'bob', groupRideId: 'r1', latitude: 0.0, longitude: 185.0, updatedAt: now, expiresAt: now.add(const Duration(seconds: 120)), ); expect(invalidLng.isValidCoordinates, isFalse); }); test('freshness and staleness calculation respects timestamp and sharing flag', () { final now = DateTime.now(); final fresh = LiveGroupPresence( userId: 'bob', groupRideId: 'r1', latitude: 35.0, longitude: -84.0, updatedAt: now.subtract(const Duration(seconds: 10)), expiresAt: now.add(const Duration(seconds: 110)), sharingEnabled: true, ); expect(fresh.isFresh, isTrue); expect(fresh.isStale, isFalse); final stale = LiveGroupPresence( userId: 'bob', groupRideId: 'r1', latitude: 35.0, longitude: -84.0, updatedAt: now.subtract(const Duration(seconds: 130)), expiresAt: now.subtract(const Duration(seconds: 10)), sharingEnabled: true, ); expect(stale.isFresh, isFalse); expect(stale.isStale, isTrue); final disabled = fresh.copyWith(sharingEnabled: false); expect(disabled.isFresh, isFalse); expect(disabled.isStale, isTrue); }); }); group('GroupRideJoinRequest Model Tests', () { test('serializes and deserializes correctly', () { final now = DateTime(2026, 9, 24, 12, 0, 0); final req = GroupRideJoinRequest( userId: 'rider_99', riderDisplayName: 'Mountain Rider', riderHandle: 'mountain_rider', riderAvatarPreset: 'helmet_red', riderAvatarUrl: 'https://example.com/avatar.jpg', createdAt: now, ); final json = req.toJson(); expect(json['userId'], equals('rider_99')); expect(json['riderDisplayName'], equals('Mountain Rider')); expect(json['riderHandle'], equals('mountain_rider')); expect(json['riderAvatarPreset'], equals('helmet_red')); expect(json['riderAvatarUrl'], equals('https://example.com/avatar.jpg')); expect(json['createdAt'], equals(now.toIso8601String())); final restored = GroupRideJoinRequest.fromJson(json); expect(restored, equals(req)); expect(restored.displayName, equals('Mountain Rider')); expect(restored.displayHandle, equals('@mountain_rider')); }); test('falls back gracefully on empty or null values', () { final req = GroupRideJoinRequest( userId: 'u1', riderDisplayName: ' ', riderHandle: null, createdAt: DateTime(2026, 1, 1), ); expect(req.displayName, equals('Rider')); expect(req.displayHandle, equals('@rider')); final fromEmptyJson = GroupRideJoinRequest.fromJson(const {}); expect(fromEmptyJson.userId, isEmpty); expect(fromEmptyJson.displayName, equals('Rider')); expect(fromEmptyJson.displayHandle, equals('@rider')); }); test('converts to GroupRideParticipant accurately', () { final now = DateTime(2026, 9, 24, 10, 0, 0); final req = GroupRideJoinRequest( userId: 'u42', riderDisplayName: 'Speed Demon', riderHandle: 'speedy', riderAvatarPreset: 'visor_dark', createdAt: now, ); final participant = req.toParticipant(); expect(participant.userId, equals('u42')); expect(participant.role, equals(GroupRideRole.participant)); expect(participant.status, equals(ParticipantStatus.requested)); expect(participant.riderDisplayName, equals('Speed Demon')); expect(participant.riderHandle, equals('speedy')); expect(participant.riderAvatarPreset, equals('visor_dark')); expect(participant.createdAt, equals(now)); expect(participant.updatedAt, equals(now)); }); test('equality and hashCode contracts hold', () { final now = DateTime(2026, 9, 24, 10, 0, 0); final r1 = GroupRideJoinRequest( userId: 'u1', riderDisplayName: 'Alice', riderHandle: 'alice', createdAt: now, ); final r2 = GroupRideJoinRequest( userId: 'u1', riderDisplayName: 'Alice', riderHandle: 'alice', createdAt: now, ); final r3 = GroupRideJoinRequest( userId: 'u2', riderDisplayName: 'Bob', riderHandle: 'bob', createdAt: now, ); expect(r1, equals(r2)); expect(r1.hashCode, equals(r2.hashCode)); expect(r1, isNot(equals(r3))); }); }); }