MotorSignal-App/test/canonical_group_rides_socia...

643 lines
21 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/features/group_rides/group_rides_hub_screen.dart';
import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart';
import 'package:motorcycle_dash/features/group_rides/group_ride_detail_screen.dart';
import 'package:motorcycle_dash/features/group_rides/live_group_ride_screen.dart';
import 'package:motorcycle_dash/features/social/rider_profile_screen.dart';
import 'package:motorcycle_dash/features/social/friends_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_service.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
import 'package:motorcycle_dash/services/auth/auth_user.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/live_broadcast_controller.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast();
AuthUser? user;
_FakeAuth({this.user});
@override
Stream<AuthState> get authStateChanges => _states.stream;
@override
AuthState get currentState => user == null
? const AuthState.unauthenticated(isFirebaseConfigured: true)
: (user!.isAnonymous
? AuthState.anonymous(user!, isFirebaseConfigured: true)
: AuthState.authenticated(user!, isFirebaseConfigured: true));
@override
AuthUser? get currentUser => user;
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => true;
@override
Future<void> deleteCurrentUser() async {}
@override
void dispose() => _states.close();
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'token';
@override
Future<void> reauthenticate({String? password}) async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> createUserWithEmailAndPassword(String email, String password,
{String? displayName}) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signOut() async {}
}
class _FakeLiveBroadcastController extends StateNotifier<LiveBroadcastState>
implements LiveBroadcastController {
_FakeLiveBroadcastController(super.initialState);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
@override
Future<void> setOptIn(bool optIn) async {}
@override
Future<void> setStatus(LivePresenceStatus status) async {}
@override
Future<void> triggerRegroup() async {}
@override
Future<void> clearRegroup() async {}
@override
void updateAppLifecycle(AppLifecycleState state) {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
const testUser = AuthUser(
uid: 'uid_lead',
email: 'rider@motorsignal.app',
displayName: 'Canyon Lead',
isAnonymous: false,
);
final testProfile = RiderProfile(
uid: 'uid_lead',
handle: 'canyon_lead',
displayName: 'Canyon Lead',
bio: 'Twisty roads and track days',
region: 'Blue Ridge, NC',
avatarPreset: 'preset_neon_cyan',
privacySettings: const ProfilePrivacySettings(
isSearchableByHandle: true,
isProfilePublic: true,
allowFriendRequests: true,
),
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final now = DateTime(2026, 9, 1, 12, 0);
final testRide = GroupRide(
id: 'ride_101',
organizerUid: 'uid_lead',
organizerHandle: 'canyon_lead',
organizerDisplayName: 'Canyon Lead',
title: 'Blue Ridge Twisties Run',
description: 'Scenic group ride through Blue Ridge twisties.',
scheduledStartAt: DateTime(2026, 10, 15, 9, 0),
departureAt: DateTime(2026, 10, 15, 9, 30),
status: GroupRideStatus.underway,
joinPolicy: GroupRideJoinPolicy.open,
visibility: GroupRideVisibility.public,
meetingPoint: const GroupRideLocation(
latitude: 35.5951,
longitude: -82.5515,
displayName: 'Asheville Meetup',
locationType: GroupRideLocationType.meetup,
),
destination: const GroupRideLocation(
latitude: 35.7951,
longitude: -82.2515,
displayName: 'Mount Mitchell Peak',
locationType: GroupRideLocationType.destination,
),
participantCount: 3,
stops: const [
GroupRideLocation(
latitude: 35.65,
longitude: -82.40,
displayName: 'Scenic Overlook Stop',
locationType: GroupRideLocationType.scenic,
),
],
createdAt: now,
updatedAt: now,
);
final List<GroupRideParticipant> testParticipants = [
GroupRideParticipant(
userId: 'uid_lead',
riderHandle: 'canyon_lead',
riderDisplayName: 'Canyon Lead',
role: GroupRideRole.lead,
status: ParticipantStatus.accepted,
createdAt: now,
updatedAt: now,
),
GroupRideParticipant(
userId: 'uid_sweep',
riderHandle: 'tail_gunner',
riderDisplayName: 'Tail Gunner',
role: GroupRideRole.sweep,
status: ParticipantStatus.accepted,
createdAt: now,
updatedAt: now,
),
GroupRideParticipant(
userId: 'uid_rider3',
riderHandle: 'moto_enthusiast',
riderDisplayName: 'Moto Enthusiast',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
createdAt: now,
updatedAt: now,
),
];
final List<LiveGroupPresence> testPresences = [
LiveGroupPresence(
userId: 'uid_lead',
groupRideId: 'ride_101',
latitude: 35.5951,
longitude: -82.5515,
heading: 45.0,
speedMph: 45.0,
accuracyMeters: 5.0,
status: LivePresenceStatus.riding,
sharingEnabled: true,
updatedAt: now,
expiresAt: now.add(const Duration(seconds: 120)),
),
LiveGroupPresence(
userId: 'uid_sweep',
groupRideId: 'ride_101',
latitude: 35.5900,
longitude: -82.5500,
heading: 40.0,
speedMph: 42.0,
accuracyMeters: 5.0,
status: LivePresenceStatus.riding,
sharingEnabled: true,
updatedAt: now.subtract(const Duration(seconds: 8)),
expiresAt: now.add(const Duration(seconds: 112)),
),
];
final List<FriendSummary> testFriends = [
FriendSummary(
uid: 'uid_sweep',
handle: 'tail_gunner',
displayName: 'Tail Gunner',
avatarPreset: 'preset_amber_flame',
friendedAt: DateTime(2026, 8, 1),
),
FriendSummary(
uid: 'uid_rider3',
handle: 'moto_enthusiast',
displayName: 'Moto Enthusiast',
avatarPreset: 'preset_electric_green',
friendedAt: DateTime(2026, 8, 10),
),
];
final List<FriendRequest> testIncomingRequests = [
FriendRequest(
requestId: 'req_1',
senderUid: 'uid_guest',
senderHandle: 'speed_demon',
senderDisplayName: 'Speed Demon',
senderAvatarPreset: 'preset_hyper_purple',
receiverUid: 'uid_lead',
sentAt: DateTime(2026, 9, 4),
),
];
final viewports = <String, Size>{
'Compact Phone (320x640)': const Size(320, 640),
'Standard Phone (400x800)': const Size(400, 800),
'Tall Phone (412x915)': const Size(412, 915),
'Small Landscape (640x320)': const Size(640, 320),
'Short Moto Mount Landscape (780x360)': const Size(780, 360),
'Standard Landscape (800x360)': const Size(800, 360),
'Wide Landscape (840x390)': const Size(840, 390),
'Medium Tablet Portrait (768x1024)': const Size(768, 1024),
'Expanded Tablet Landscape (1024x768)': const Size(1024, 768),
};
group('GroupRidesHubScreen Responsive Viewport Matrix', () {
for (final entry in viewports.entries) {
testWidgets('Renders cleanly without overflow on ${entry.key}',
(tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final fakeAuth = _FakeAuth(user: testUser);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
upcomingGroupRidesProvider.overrideWith(
(ref) => Stream.value([testRide]),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const GroupRidesHubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('GROUP RIDES'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
});
group('CreateGroupRideScreen Responsive Matrix', () {
for (final entry in [
const MapEntry('Compact (400x800)', Size(400, 800)),
const MapEntry('Short Landscape (800x360)', Size(800, 360)),
const MapEntry('Expanded Tablet (1024x768)', Size(1024, 768)),
]) {
testWidgets('Renders cleanly on ${entry.key}', (tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final fakeAuth = _FakeAuth(user: testUser);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CREATE GROUP RIDE'), findsOneWidget);
expect(find.text('1. RIDE DETAILS'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
});
group('GroupRideDetailScreen Responsive Matrix', () {
for (final entry in [
const MapEntry('Compact (400x800)', Size(400, 800)),
const MapEntry('Short Landscape (800x360)', Size(800, 360)),
const MapEntry('Expanded Tablet (1024x768)', Size(1024, 768)),
]) {
testWidgets('Renders 2-column on wide and details cleanly on ${entry.key}',
(tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final fakeAuth = _FakeAuth(user: testUser);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
groupRideStreamProvider('ride_101').overrideWith(
(ref) => Stream.value(testRide),
),
groupRideParticipantsProvider('ride_101').overrideWith(
(ref) => Stream.value(testParticipants),
),
groupRideLivePresenceProvider('ride_101').overrideWith(
(ref) => Stream.value(testPresences),
),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const GroupRideDetailScreen(rideId: 'ride_101'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Blue Ridge Twisties Run'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets(
'Attach Saved Route sheet empty state does not overflow in landscape (800x360) with keyboard inset and portrait (#75)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
final fakeAuth = _FakeAuth(user: testUser);
for (final viewportSize in const [
Size(800, 360),
Size(640, 320),
Size(400, 800),
]) {
final isLandscape = viewportSize.width > viewportSize.height;
tester.view.physicalSize = viewportSize;
tester.view.devicePixelRatio = 1.0;
tester.view.resetViewInsets();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
groupRideStreamProvider('ride_101').overrideWith(
(ref) => Stream.value(testRide),
),
groupRideParticipantsProvider('ride_101').overrideWith(
(ref) => Stream.value(testParticipants),
),
groupRideLivePresenceProvider('ride_101').overrideWith(
(ref) => Stream.value(testPresences),
),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
userRoutesProvider('uid_lead').overrideWith(
(ref) => Stream.value(const []),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const GroupRideDetailScreen(rideId: 'ride_101'),
),
),
);
await tester.pumpAndSettle();
final attachBtn =
find.byKey(const ValueKey('attach_saved_route_button'));
await tester.scrollUntilVisible(
attachBtn,
150.0,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(attachBtn);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 300));
expect(tester.takeException(), isNull);
expect(find.text('ATTACH SAVED ROUTE'), findsWidgets);
expect(find.text('You have no saved routes yet.'), findsOneWidget);
expect(find.text('CREATE A ROUTE'), findsOneWidget);
final createBtnRect =
tester.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
expect(
createBtnRect.bottom,
lessThanOrEqualTo(viewportSize.height),
reason: 'CREATE A ROUTE button should fit inside $viewportSize',
);
if (isLandscape) {
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 200));
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text('CREATE A ROUTE'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final createBtnKeyboardRect = tester
.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
expect(
createBtnKeyboardRect.bottom,
lessThanOrEqualTo(viewportSize.height - 180 + 1.0),
reason:
'CREATE A ROUTE should remain above keyboard at $viewportSize',
);
}
// Dismiss sheet
Navigator.of(tester.element(find.text('CREATE A ROUTE'))).pop();
await tester.pumpAndSettle();
}
});
});
group('LiveGroupRideScreen Responsive Matrix', () {
for (final entry in [
const MapEntry('Compact (400x800)', Size(400, 800)),
const MapEntry('Short Landscape (800x360)', Size(800, 360)),
const MapEntry('Expanded Tablet (1024x768)', Size(1024, 768)),
]) {
testWidgets('Renders map, status bar, and controls on ${entry.key}',
(tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final fakeAuth = _FakeAuth(user: testUser);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
groupRideStreamProvider('ride_101').overrideWith(
(ref) => Stream.value(testRide),
),
groupRideParticipantsProvider('ride_101').overrideWith(
(ref) => Stream.value(testParticipants),
),
groupRideLivePresenceProvider('ride_101').overrideWith(
(ref) => Stream.value(testPresences),
),
liveBroadcastControllerProvider('ride_101').overrideWith(
(ref) => _FakeLiveBroadcastController(
const LiveBroadcastState(
isOptedIn: true,
isBroadcasting: true,
status: LivePresenceStatus.riding,
),
),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const LiveGroupRideScreen(rideId: 'ride_101'),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('live_ride_broadcast_switch')),
findsOneWidget);
expect(find.byKey(const ValueKey('live_ride_recenter_button')),
findsOneWidget);
expect(tester.takeException(), isNull);
});
}
});
group('RiderProfileScreen & FriendsScreen Responsive Matrix', () {
for (final entry in [
const MapEntry('Compact (400x800)', Size(400, 800)),
const MapEntry('Wide (1024x768)', Size(1024, 768)),
]) {
testWidgets('RiderProfileScreen renders on ${entry.key}', (tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final fakeAuth = _FakeAuth(user: testUser);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const RiderProfileScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Rider Profile'), findsOneWidget);
expect(find.text('PRIVACY CONTROLS'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('FriendsScreen renders on ${entry.key}', (tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final fakeAuth = _FakeAuth(user: testUser);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
myFriendsProvider.overrideWith(
(ref) => Stream.value(testFriends),
),
myIncomingRequestsProvider.overrideWith(
(ref) => Stream.value(testIncomingRequests),
),
myOutgoingRequestsProvider.overrideWith(
(ref) => Stream.value(<FriendRequest>[]),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const FriendsScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Friends & Riders'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
});
}