581 lines
19 KiB
Dart
581 lines
19 KiB
Dart
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<ArgumentError>()),
|
|
);
|
|
|
|
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<ArgumentError>()),
|
|
);
|
|
|
|
// 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<ArgumentError>()),
|
|
);
|
|
|
|
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<ArgumentError>()),
|
|
);
|
|
|
|
// 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<ArgumentError>()),
|
|
);
|
|
|
|
// Deserialization validation
|
|
final jsonEmpty = baseRide.toJson()..['clubId'] = '';
|
|
expect(
|
|
() => GroupRide.fromJson(jsonEmpty),
|
|
throwsA(isA<ArgumentError>()),
|
|
);
|
|
|
|
final jsonWhitespace = baseRide.toJson()..['clubId'] = ' \t\n ';
|
|
expect(
|
|
() => GroupRide.fromJson(jsonWhitespace),
|
|
throwsA(isA<ArgumentError>()),
|
|
);
|
|
|
|
final jsonInternalSpace = baseRide.toJson()..['clubId'] = 'club alpha';
|
|
expect(
|
|
() => GroupRide.fromJson(jsonInternalSpace),
|
|
throwsA(isA<ArgumentError>()),
|
|
);
|
|
|
|
final jsonTooLong = baseRide.toJson()..['clubId'] = 'c' * 65;
|
|
expect(
|
|
() => GroupRide.fromJson(jsonTooLong),
|
|
throwsA(isA<ArgumentError>()),
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|