MotorSignal-App/test/canonical_whole_app_regress...

1186 lines
46 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:shared_preferences/shared_preferences.dart';
import 'package:drift/native.dart';
import 'package:geolocator/geolocator.dart';
import 'package:drift/drift.dart' as drift;
import 'package:motorcycle_dash/core/router.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/core/widgets/main_layout.dart';
import 'package:motorcycle_dash/database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample;
import 'package:motorcycle_dash/models/field_test_event.dart';
import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart';
import 'package:motorcycle_dash/features/map/map_screen.dart';
import 'package:motorcycle_dash/features/history/history_screen.dart';
import 'package:motorcycle_dash/features/history/ride_detail_screen.dart';
import 'package:motorcycle_dash/features/records/records_screen.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/garage/garage_screen.dart';
import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart';
import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart';
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
import 'package:motorcycle_dash/features/settings/settings_screen.dart';
import 'package:motorcycle_dash/features/settings/account_screen.dart';
import 'package:motorcycle_dash/features/entitlements/pro_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/features/settings/diagnostics_screen.dart';
import 'package:motorcycle_dash/features/settings/field_test_live_log_screen.dart';
import 'package:motorcycle_dash/features/settings/field_test_report_screen.dart';
import 'package:motorcycle_dash/features/field_test/field_test_pre_ride_check_screen.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/routes/route_planner_screen.dart';
import 'package:motorcycle_dash/features/routes/route_detail_screen.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/models/motorcycle_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.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/billing/billing_providers.dart';
import 'package:motorcycle_dash/services/billing/fake_billing_service.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/location_service.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/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
import 'package:motorcycle_dash/services/routes/route_service.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
// Canonical 9-viewport regression matrix definitions
const Map<String, Size> canonicalViewports = {
'compact_portrait_320x640': Size(320, 640),
'standard_portrait_400x800': Size(400, 800),
'pixel7_portrait_412x915': Size(412, 915),
'compact_landscape_640x320': Size(640, 320),
'short_landscape_780x360': Size(780, 360),
'standard_landscape_800x360': Size(800, 360),
'wide_landscape_840x390': Size(840, 390),
'portrait_tablet_768x1024': Size(768, 1024),
'landscape_tablet_1024x768': Size(1024, 768),
};
// IME virtual keyboard inset viewports
const Map<String, Size> imeViewports = {
'compact_portrait_ime': Size(320, 640),
'standard_portrait_ime': Size(400, 800),
'compact_landscape_ime': Size(640, 320),
};
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
AuthUser? user;
final _states = StreamController<AuthState>.broadcast();
_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 => user?.emailVerified ?? false;
@override
Future<void> deleteCurrentUser() async {
user = null;
_states.add(currentState);
}
@override
void dispose() => _states.close();
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake-token';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeLocationService extends ChangeNotifier implements LocationService {
final StreamController<Position> _controller = StreamController<Position>.broadcast();
@override
Stream<Position> getFilteredPositionStream() => _controller.stream;
@override
Future<bool> requestPermissions() async => true;
@override
Future<bool> hasPermissions() async => true;
@override
void Function(Position, bool, String?)? onGpsDiagnostic;
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeMotorcycleRepository implements MotorcycleRepository {
@override
Future<List<MotorcycleYear>> getYears() async => [MotorcycleYear(2024)];
@override
Future<List<MotorcycleMake>> getMakes(int year) async => [
MotorcycleMake(id: 'triumph', name: 'Triumph'),
];
@override
Future<List<MotorcycleModel>> getModels(int year, String makeId) async => [
MotorcycleModel(
id: 'speed-triple',
name: 'Speed Triple 1200 RS',
year: 2024,
makeId: 'triumph',
),
];
@override
Future<List<MotorcycleVariant>> getVariants(int year, String makeId, String modelId) async => [];
@override
Future<MotorcycleSpecification?> getSpecifications(MotorcycleSelection selection) async => null;
}
class _FakeFieldTestLogger implements FieldTestLogger {
@override
int? get activeRideId => null;
@override
bool get isSessionActive => false;
@override
DateTime? get sessionStartTime => null;
@override
double get currentQualityEstimate => 100.0;
@override
double get currentSampleRateHz => 20.0;
@override
double get lastGpsAccuracy => 2.5;
@override
int get lastGpsAgeMs => 50;
@override
double get averageTimingJitterMs => 1.2;
@override
int get currentQueueDepth => 0;
@override
int get peakQueueDepth => 0;
@override
int get queueCapacity => 1000;
@override
int get totalEnqueued => 0;
@override
int get totalFlushed => 0;
@override
int get droppedEventsCount => 0;
@override
String? get oldestDroppedItem => null;
@override
String? get newestDroppedItem => null;
@override
int get lastFlushDurationMs => 0;
@override
int get failedFlushCount => 0;
int get telemetryGapCount => 0;
int get gpsStaleCount => 0;
int get gpsJumpRejectCount => 0;
int get imuSaturationCount => 0;
int get accelerationEventCount => 0;
int get brakingEventCount => 0;
@override
List<FieldTestEvent> get timelineEvents => const [];
@override
Stream<List<FieldTestEvent>> get timelineStream => const Stream.empty();
@override
void dispose() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeGroupRideService implements GroupRideService {
@override
Stream<GroupRide?> groupRideStream(String groupRideId) => watchGroupRide(groupRideId);
@override
Stream<List<GroupRideParticipant>> participantsStream(String rideId) => watchParticipants(rideId);
Stream<GroupRide?> watchGroupRide(String rideId) {
return Stream.value(GroupRide(
id: rideId,
organizerUid: 'user-1',
organizerHandle: 'canyon_lead',
organizerDisplayName: 'Canyon Lead',
title: 'Weekend Mountain Ride',
description: 'Twisty canyon roads and scenic outlooks',
meetingPoint: const GroupRideLocation(
latitude: 37.0,
longitude: -122.0,
displayName: 'Mountain Viewpoint',
locationType: GroupRideLocationType.meetup,
),
scheduledStartAt: DateTime.now().add(const Duration(hours: 2)),
departureAt: DateTime.now().add(const Duration(hours: 2, minutes: 30)),
visibility: GroupRideVisibility.public,
joinPolicy: GroupRideJoinPolicy.open,
status: GroupRideStatus.scheduled,
participantCount: 3,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
));
}
Stream<List<GroupRide>> watchUpcomingRides() {
return Stream.value([
GroupRide(
id: 'ride-1',
organizerUid: 'user-1',
organizerHandle: 'canyon_lead',
organizerDisplayName: 'Canyon Lead',
title: 'Morning Cruise',
description: 'Casual morning cruise through the hills',
meetingPoint: const GroupRideLocation(
latitude: 37.0,
longitude: -122.0,
displayName: 'Basecamp',
locationType: GroupRideLocationType.meetup,
),
scheduledStartAt: DateTime.now().add(const Duration(hours: 4)),
departureAt: DateTime.now().add(const Duration(hours: 4, minutes: 30)),
visibility: GroupRideVisibility.public,
joinPolicy: GroupRideJoinPolicy.open,
status: GroupRideStatus.scheduled,
participantCount: 4,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
]);
}
Stream<List<GroupRide>> watchActiveRides() => Stream.value([]);
Stream<List<GroupRide>> watchMyRides() => Stream.value([]);
@override
Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) => watchUpcomingRides();
@override
Stream<List<GroupRide>> myRidesStream({required String callerUid}) => watchMyRides();
Stream<List<GroupRideParticipant>> watchParticipants(String rideId) => Stream.value([
GroupRideParticipant(
userId: 'user-1',
riderHandle: 'lead_rider',
riderDisplayName: 'Leader Rider',
role: GroupRideRole.lead,
status: ParticipantStatus.accepted,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
]);
@override
Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) => Stream.value(null);
@override
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) => Stream.value(null);
@override
Future<void> clearLivePresence({
required String callerUid,
required String groupRideId,
}) async {}
Future<void> updateLivePresence({
required String callerUid,
required String groupRideId,
required LiveGroupPresence presence,
}) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeRouteService implements RouteService {
final Map<String, MotorSignalRoute> _routes = {
'route-1': MotorSignalRoute(
id: 'route-1',
ownerUid: 'user-1',
ownerHandle: 'testrider',
title: 'Pacific Coast Highway',
description: 'Scenic ocean run',
visibility: RouteVisibility.public,
stops: const [
RouteStop(
id: 's1',
order: 0,
type: RouteStopType.start,
displayName: 'Santa Monica Pier',
latitude: 34.01,
longitude: -118.49,
address: 'Santa Monica, CA',
notes: 'Meet at pier entrance',
),
RouteStop(
id: 's2',
order: 1,
type: RouteStopType.destination,
displayName: 'Malibu Overlook',
latitude: 34.03,
longitude: -118.68,
address: 'Malibu, CA',
notes: 'End of ride photo spot',
),
],
straightLineDistanceMeters: 25000,
source: RouteSource.planner,
version: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
};
@override
Future<MotorSignalRoute?> getRoute(String routeId) async => _routes[routeId];
Stream<MotorSignalRoute?> watchRoute(String routeId) => Stream.value(_routes[routeId]);
@override
Stream<MotorSignalRoute?> routeStream(String routeId) => watchRoute(routeId);
Stream<List<MotorSignalRoute>> watchMyRoutes() => Stream.value(_routes.values.toList());
Stream<List<MotorSignalRoute>> watchPublicRoutes() => Stream.value(_routes.values.toList());
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeRideStateMachine extends StateNotifier<RideSession>
implements RideStateMachine {
_FakeRideStateMachine(super.initialState);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeMapProvider implements AppMapProvider {
@override
AppMapType get type => AppMapType.openStreetMap;
@override
String get name => 'FakeMap';
@override
Widget buildMapView({
required BuildContext context,
required AppLatLng center,
required double zoom,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
required MapConfig config,
required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction,
}) {
return Container(
key: const ValueKey('fake_map_view'),
color: Colors.black,
child: const Center(child: Text('MAP_VIEW')),
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late SharedPreferences prefs;
late _FakeAuth authService;
late FakeBillingService billingService;
late InMemoryEntitlementStorage entitlementStorage;
late _FakeGroupRideService groupRideService;
late _FakeRouteService routeService;
late _FakeLocationService locationService;
late _FakeMotorcycleRepository motorcycleRepo;
late _FakeFieldTestLogger fieldTestLogger;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
SharedPreferences.setMockInitialValues({
'app_theme_mode': 'dark',
});
prefs = await SharedPreferences.getInstance();
authService = _FakeAuth(
user: const AuthUser(
uid: 'test-user-123',
email: 'rider@example.com',
displayName: 'Test Rider',
),
);
billingService = FakeBillingService();
entitlementStorage = InMemoryEntitlementStorage();
groupRideService = _FakeGroupRideService();
routeService = _FakeRouteService();
locationService = _FakeLocationService();
motorcycleRepo = _FakeMotorcycleRepository();
fieldTestLogger = _FakeFieldTestLogger();
// Populate a test motorcycle in Drift DB
await db.into(db.garageMotorcycles).insert(
GarageMotorcyclesCompanion.insert(
year: 2024,
makeId: 'triumph',
makeName: 'Triumph',
modelId: 'speed-triple',
modelName: 'Speed Triple 1200 RS',
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
isDefault: const drift.Value(true),
),
);
// Populate a completed test ride in Drift DB
await db.into(db.rides).insert(
RidesCompanion.insert(
startTime: DateTime.now().subtract(const Duration(hours: 1)),
endTime: drift.Value(DateTime.now().subtract(const Duration(minutes: 10))),
distanceMiles: const drift.Value(24.5),
movingTime: const drift.Value(1800),
topSpeedMph: const drift.Value(72.4),
maxLeanLeft: const drift.Value(42.1),
maxLeanRight: const drift.Value(44.8),
maxBrakingG: const drift.Value(0.88),
maxAccelG: const drift.Value(0.65),
),
);
});
tearDown(() async {
locationService.dispose();
authService.dispose();
await db.close();
});
Widget buildTestApp({
required Widget child,
ThemeMode themeMode = ThemeMode.dark,
AuthUser? customUser,
}) {
final activeAuth = customUser != null ? _FakeAuth(user: customUser) : authService;
return ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(activeAuth),
billingServiceProvider.overrideWithValue(billingService),
entitlementStorageProvider.overrideWithValue(entitlementStorage),
groupRideServiceProvider.overrideWithValue(groupRideService),
routeServiceProvider.overrideWithValue(routeService),
activeMapProvider.overrideWithValue(_FakeMapProvider()),
locationServiceProvider.overrideWithValue(locationService),
motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo),
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])),
locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)),
myRiderProfileProvider.overrideWith((ref) => Stream.value(
RiderProfile(
uid: 'user-1',
handle: 'testrider',
displayName: 'Test Rider',
bio: 'Motorcycle enthusiast',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
)),
myFriendsProvider.overrideWith((ref) => Stream.value([])),
myIncomingRequestsProvider.overrideWith((ref) => Stream.value([])),
myOutgoingRequestsProvider.overrideWith((ref) => Stream.value([])),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
home: Scaffold(body: child),
),
);
}
Future<void> pumpView(WidgetTester tester, Widget app) async {
await tester.pumpWidget(app);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
}
Future<void> unmountView(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
// =========================================================================
// 1. WHOLE-APP CANONICAL 9-VIEWPORT RESPONSIVE MATRIX
// =========================================================================
group('UI-2I Whole-App 9-Viewport Responsive Matrix', () {
testWidgets('DashboardScreen renders cleanly across all 9 canonical viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const DashboardScreen()));
expect(find.byType(DashboardScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('MapScreen renders cleanly across all 9 canonical viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const MapScreen()));
expect(find.byType(MapScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('HistoryScreen renders cleanly across all 9 canonical viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const HistoryScreen()));
expect(find.byType(HistoryScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('RecordsScreen renders cleanly across all 9 canonical viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const RecordsScreen()));
expect(find.byType(RecordsScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('GarageScreen renders cleanly across all 9 canonical viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const GarageScreen()));
expect(find.byType(GarageScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('SettingsScreen renders cleanly across all 9 canonical viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const SettingsScreen()));
expect(find.byType(SettingsScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('AccountScreen (authenticated) renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const AccountScreen()));
expect(find.byType(AccountScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('AccountScreen (unauthenticated) renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const AccountScreen(), customUser: null));
expect(find.byType(AccountScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('ProScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const ProScreen()));
expect(find.byType(ProScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('DiagnosticsScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const DiagnosticsScreen()));
expect(find.byType(DiagnosticsScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('FieldTestPreRideCheckScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const FieldTestPreRideCheckScreen()));
expect(find.byType(FieldTestPreRideCheckScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('FieldTestLiveLogScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const FieldTestLiveLogScreen()));
expect(find.byType(FieldTestLiveLogScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('FieldTestReportScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const FieldTestReportScreen()));
expect(find.byType(FieldTestReportScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('GroupRidesHubScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const GroupRidesHubScreen()));
expect(find.byType(GroupRidesHubScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('CreateGroupRideScreen renders cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const CreateGroupRideScreen()));
expect(find.byType(CreateGroupRideScreen), findsOneWidget, reason: 'Failed on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('GroupRideDetailScreen & LiveGroupRideScreen render cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const GroupRideDetailScreen(rideId: 'ride-1')));
expect(find.byType(GroupRideDetailScreen), findsOneWidget, reason: 'Failed Detail on ${entry.key}');
await pumpView(tester, buildTestApp(child: const LiveGroupRideScreen(rideId: 'ride-1')));
expect(find.byType(LiveGroupRideScreen), findsOneWidget, reason: 'Failed Live on ${entry.key}');
expect(tester.takeException(), isNull);
}
await unmountView(tester);
});
testWidgets('RoutePlannerScreen & RouteDetailScreen render cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const RoutePlannerScreen()));
expect(find.byType(RoutePlannerScreen), findsOneWidget, reason: 'Failed Planner on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on RoutePlanner on ${entry.key}');
await pumpView(tester, buildTestApp(child: const RouteDetailScreen(routeId: 'route-1')));
expect(find.byType(RouteDetailScreen), findsOneWidget, reason: 'Failed RouteDetail on ${entry.key}');
expect(tester.takeException(), isNull, reason: 'Exception on RouteDetail on ${entry.key}');
}
await unmountView(tester);
});
testWidgets('AddMotorcycleScreen & MotorcycleDetailScreen render cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const AddMotorcycleScreen()));
expect(find.byType(AddMotorcycleScreen), findsOneWidget, reason: 'Failed AddMotorcycle on ${entry.key}');
await pumpView(tester, buildTestApp(child: const MotorcycleDetailScreen(motorcycleId: 1)));
expect(find.byType(MotorcycleDetailScreen), findsOneWidget, reason: 'Failed MotorcycleDetail on ${entry.key}');
expect(tester.takeException(), isNull);
}
await unmountView(tester);
});
testWidgets('RiderProfileScreen & FriendsScreen render cleanly across all 9 viewports', (tester) async {
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: const RiderProfileScreen()));
expect(find.byType(RiderProfileScreen), findsOneWidget, reason: 'Failed RiderProfile on ${entry.key}');
await pumpView(tester, buildTestApp(child: const FriendsScreen()));
expect(find.byType(FriendsScreen), findsOneWidget, reason: 'Failed FriendsScreen on ${entry.key}');
expect(tester.takeException(), isNull);
}
await unmountView(tester);
});
testWidgets('RideDetailScreen renders cleanly across all 9 viewports', (tester) async {
final ride = Ride(
id: 1,
startTime: DateTime.now().subtract(const Duration(hours: 1)),
endTime: DateTime.now().subtract(const Duration(minutes: 10)),
distanceMiles: 24.5,
movingTime: 1800,
topSpeedMph: 72.4,
maxLeanLeft: 42.1,
maxLeanRight: 44.8,
maxBrakingG: 0.88,
maxAccelG: 0.65,
distance: 24.5,
stoppedTime: 100,
averageSpeed: 48.0,
topSpeed: 72.4,
elevationGain: 350.0,
elevationLoss: 320.0,
);
for (final entry in canonicalViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(tester, buildTestApp(child: RideDetailScreen(ride: ride)));
expect(find.byType(RideDetailScreen), findsOneWidget, reason: 'Failed RideDetail on ${entry.key}');
expect(tester.takeException(), isNull);
}
await unmountView(tester);
});
});
// =========================================================================
// 2. IME KEYBOARD INSET RESILIENCY MATRIX
// =========================================================================
group('UI-2I IME Keyboard-Sensitive Form Resiliency Matrix', () {
testWidgets('AddMotorcycleScreen remains scrollable and submit reachable with virtual keyboard', (tester) async {
for (final entry in imeViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
tester.view.viewPadding = const FakeViewPadding(bottom: 300);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetViewPadding();
});
await pumpView(tester, buildTestApp(child: const AddMotorcycleScreen()));
expect(find.byType(AddMotorcycleScreen), findsOneWidget);
expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard');
}
await unmountView(tester);
});
testWidgets('CreateGroupRideScreen remains scrollable with virtual keyboard', (tester) async {
for (final entry in imeViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
tester.view.viewPadding = const FakeViewPadding(bottom: 300);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetViewPadding();
});
await pumpView(tester, buildTestApp(child: const CreateGroupRideScreen()));
expect(find.byType(CreateGroupRideScreen), findsOneWidget);
expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard');
}
await unmountView(tester);
});
testWidgets('AccountScreen auth form remains scrollable with virtual keyboard', (tester) async {
for (final entry in imeViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
tester.view.viewPadding = const FakeViewPadding(bottom: 300);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetViewPadding();
});
await pumpView(tester, buildTestApp(
child: const AccountScreen(),
customUser: null,
));
expect(find.byType(AccountScreen), findsOneWidget);
expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard');
}
await unmountView(tester);
});
testWidgets('RoutePlannerScreen search & stop input remains reachable with virtual keyboard', (tester) async {
for (final entry in imeViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
tester.view.viewPadding = const FakeViewPadding(bottom: 300);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetViewPadding();
});
await pumpView(tester, buildTestApp(child: const RoutePlannerScreen()));
expect(find.byType(RoutePlannerScreen), findsOneWidget);
expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard');
}
await unmountView(tester);
});
testWidgets('RiderProfileScreen handle & bio inputs remain reachable with virtual keyboard', (tester) async {
for (final entry in imeViewports.entries) {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
tester.view.viewPadding = const FakeViewPadding(bottom: 300);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetViewPadding();
});
await pumpView(tester, buildTestApp(child: const RiderProfileScreen()));
expect(find.byType(RiderProfileScreen), findsOneWidget);
expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard');
}
await unmountView(tester);
});
});
// =========================================================================
// 3. THEME SWITCHING & STATE PRESERVATION MATRIX
// =========================================================================
group('UI-2I Theme Switching & State Preservation Matrix', () {
testWidgets('Seamless Theme Transition Dark -> Light -> System -> Dark preserves screen state', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final themeModes = [ThemeMode.dark, ThemeMode.light, ThemeMode.system, ThemeMode.dark];
for (final mode in themeModes) {
await pumpView(tester, buildTestApp(
child: const DashboardScreen(),
themeMode: mode,
));
expect(find.byType(DashboardScreen), findsOneWidget);
expect(tester.takeException(), isNull);
}
await unmountView(tester);
});
testWidgets('MainLayout navigation shell remains intact when resizing across breakpoints', (tester) async {
final sizes = [
const Size(320, 640), // Compact
const Size(640, 320), // Compact Landscape
const Size(768, 1024), // Medium Tablet
const Size(1024, 768), // Expanded Tablet
const Size(400, 800), // Standard Phone
];
for (final size in sizes) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await pumpView(
tester,
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(authService),
billingServiceProvider.overrideWithValue(billingService),
entitlementStorageProvider.overrideWithValue(entitlementStorage),
groupRideServiceProvider.overrideWithValue(groupRideService),
routeServiceProvider.overrideWithValue(routeService),
activeMapProvider.overrideWithValue(_FakeMapProvider()),
locationServiceProvider.overrideWithValue(locationService),
motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo),
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])),
locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)),
],
child: MaterialApp.router(
routerConfig: appRouter,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.dark,
),
),
);
expect(find.byType(MainLayout), findsOneWidget, reason: 'Failed MainLayout at $size');
expect(tester.takeException(), isNull);
}
await unmountView(tester);
});
});
// =========================================================================
// 4. RIDER SAFETY UI GATES VERIFICATION
// =========================================================================
group('UI-2I Rider Safety UI Gate Invariants', () {
testWidgets('Active recording ride does not trigger disruptive modal sheets', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final activeSession = RideSession(
state: RideState.riding,
startTime: DateTime.now().subtract(const Duration(minutes: 5)),
);
await pumpView(
tester,
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(authService),
billingServiceProvider.overrideWithValue(billingService),
entitlementStorageProvider.overrideWithValue(entitlementStorage),
groupRideServiceProvider.overrideWithValue(groupRideService),
routeServiceProvider.overrideWithValue(routeService),
activeMapProvider.overrideWithValue(_FakeMapProvider()),
locationServiceProvider.overrideWithValue(locationService),
motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo),
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])),
locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)),
rideStateMachineProvider.overrideWith((ref) => _FakeRideStateMachine(activeSession)),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.darkTheme,
home: const Scaffold(body: DashboardScreen()),
),
),
);
// Ensure no BottomSheet or AlertDialog is automatically presented
expect(find.byType(BottomSheet), findsNothing);
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(DashboardScreen), findsOneWidget);
await unmountView(tester);
});
});
// =========================================================================
// 5. NAVIGATION FLOWS & DEEP ROUTE TRANSITIONS
// =========================================================================
group('UI-2I Navigation Flows & Deep Route Transitions', () {
testWidgets('App shell navigates cleanly across all primary destinations', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final destinations = [
'/',
'/map',
'/history',
'/records',
'/garage',
'/settings',
'/',
];
for (final path in destinations) {
appRouter.go(path);
await pumpView(
tester,
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(authService),
billingServiceProvider.overrideWithValue(billingService),
entitlementStorageProvider.overrideWithValue(entitlementStorage),
groupRideServiceProvider.overrideWithValue(groupRideService),
routeServiceProvider.overrideWithValue(routeService),
activeMapProvider.overrideWithValue(_FakeMapProvider()),
locationServiceProvider.overrideWithValue(locationService),
motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo),
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])),
locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)),
],
child: MaterialApp.router(
routerConfig: appRouter,
theme: AppTheme.darkTheme,
),
),
);
expect(tester.takeException(), isNull, reason: 'Navigation failed to $path');
}
await unmountView(tester);
});
testWidgets('Deep secondary route transitions render cleanly without exception', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final deepRoutes = [
'/history/1',
'/garage/1',
'/routes/route-1',
'/group-rides/ride-1',
'/settings/account',
'/settings/pro',
'/settings/diagnostics',
'/settings/diagnostics/live-log',
'/settings/diagnostics/report',
];
for (final path in deepRoutes) {
appRouter.go(path);
await pumpView(
tester,
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(authService),
billingServiceProvider.overrideWithValue(billingService),
entitlementStorageProvider.overrideWithValue(entitlementStorage),
groupRideServiceProvider.overrideWithValue(groupRideService),
routeServiceProvider.overrideWithValue(routeService),
activeMapProvider.overrideWithValue(_FakeMapProvider()),
locationServiceProvider.overrideWithValue(locationService),
motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo),
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])),
locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)),
],
child: MaterialApp.router(
routerConfig: appRouter,
theme: AppTheme.darkTheme,
),
),
);
expect(tester.takeException(), isNull, reason: 'Deep route failed for $path');
}
await unmountView(tester);
});
});
}