import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/app/router/app_router.dart'; import 'package:glp1_companion/core/database/app_database.dart'; import 'package:glp1_companion/core/platform/notification_service.dart'; import 'package:glp1_companion/features/auth/presentation/controllers/dev_auth_bypass.dart'; import 'package:glp1_companion/features/auth/presentation/screens/login_screen.dart'; import 'package:glp1_companion/features/medication/presentation/controllers/medication_controller.dart'; import 'package:glp1_companion/main.dart'; /// Shell-level integration tests: the adaptive scaffold must route between /// every destination — `/medication`, `/nutrition`, `/exercise`, /// `/meal-planner`, `/recipes`, `/pantry`, `/settings` — under both an /// authenticated session and the debug dev bypass, on desktop (seven-item /// navigation rail) and mobile (bottom navigation bar + secondary /// "More" drawer) layouts. /// /// Supabase is never booted: the auth guard reads `isAuthenticatedProvider` /// (overridden per scenario) and the drift database is an in-memory /// stand-in, so the whole app graph runs offline and deterministically. /// No-op reminders: `flutter_local_notifications` has no test host. class _NoopNotificationService implements INotificationService { @override Future initialize() async {} @override Future requestPermissions() async => false; @override Future scheduleDoseReminder({ required DateTime scheduledAt, required String title, required String body, int id = kDoseReminderNotificationId, String? payload, }) async => false; @override Future cancelReminder(int id) async {} @override Future cancelAllReminders() async {} } void main() { late AppDatabase database; setUp(() { database = AppDatabase.forTesting(NativeDatabase.memory()); }); tearDown(() => database.close()); /// Core overrides backing every scenario with offline doubles. List baseOverrides() => [ appDatabaseProvider.overrideWithValue(database), notificationServiceProvider .overrideWithValue(_NoopNotificationService()), ]; Future pumpApp( WidgetTester tester, { Size viewport = const Size(1280, 1400), List extraOverrides = const [], }) async { tester.view.physicalSize = viewport; tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( ProviderScope( overrides: [...baseOverrides(), ...extraOverrides], child: const GLP1CompanionApp(), ), ); await tester.pumpAndSettle(); } /// Unmounts the tree and flushes the zero-duration timers drift schedules /// when its query streams are cancelled during container disposal, which /// would otherwise trip the test framework's pending-timers invariant. Future unmountAndFlushTimers(WidgetTester tester) async { await tester.pumpWidget(const SizedBox()); await tester.pump(const Duration(milliseconds: 1)); } /// Taps the rail destination [railLabel] and asserts the shell switched to /// the screen titled [title] with [index] selected. /// /// [titleMatches] accounts for destinations whose rail label equals the /// screen title (e.g. "Settings"), which makes [find.text] ambiguous. Future expectRailHop( WidgetTester tester, { required String railLabel, required int index, required String title, int titleMatches = 1, }) async { await tester.tap(find.text(railLabel).first); await tester.pumpAndSettle(); expect(find.text(title), findsNWidgets(titleMatches)); expect( tester.widget(find.byType(NavigationRail)).selectedIndex, index, ); } Future verifyAllRailRoutes(WidgetTester tester) async { await expectRailHop( tester, railLabel: 'Nutrition', index: 1, title: 'Nutrition & Hydration', ); await expectRailHop( tester, railLabel: 'Exercise', index: 2, title: 'Exercise Log', ); await expectRailHop( tester, railLabel: 'Meal Plan', index: 3, title: 'Meal Planner', ); await expectRailHop( tester, railLabel: 'Recipes', index: 4, title: 'Recipe Vault', ); await expectRailHop( tester, railLabel: 'Pantry', index: 5, title: 'Pantry & Shopping Delta', ); await expectRailHop( tester, railLabel: 'Settings', index: 6, // Rail label + screen app-bar title both read "Settings". title: 'Settings', titleMatches: 2, ); await expectRailHop( tester, railLabel: 'Medication', index: 0, title: 'Medication Tracker', ); } testWidgets( 'authenticated sessions start on /medication and the rail switches ' 'every core route', (tester) async { await pumpApp( tester, extraOverrides: [isAuthenticatedProvider.overrideWithValue(true)], ); // Guard admits the session without visiting /login. expect(find.byType(LoginScreen), findsNothing); expect(find.text('Development mode - not signed in'), findsNothing); expect(find.byType(NavigationRail), findsOneWidget); expect(find.text('Medication Tracker'), findsOneWidget); await verifyAllRailRoutes(tester); await unmountAndFlushTimers(tester); }); testWidgets('dev-bypass sessions reach every route and wear the dev banner', (tester) async { await pumpApp( tester, extraOverrides: [devAuthBypassProvider.overrideWith((ref) => true)], ); expect(find.byType(LoginScreen), findsNothing); expect(find.text('Development mode - not signed in'), findsOneWidget); await verifyAllRailRoutes(tester); // The banner persists across route switches. expect(find.text('Development mode - not signed in'), findsOneWidget); await unmountAndFlushTimers(tester); }); testWidgets('mobile widths use the bottom navigation bar for core routes', (tester) async { await pumpApp( tester, viewport: const Size(390, 2400), extraOverrides: [devAuthBypassProvider.overrideWith((ref) => true)], ); expect(find.byType(NavigationBar), findsOneWidget); expect(find.byType(NavigationRail), findsNothing); Future tapBar(String label, int index, String title) async { await tester.tap(find.text(label)); await tester.pumpAndSettle(); expect(find.text(title), findsOneWidget); expect( tester.widget(find.byType(NavigationBar)).selectedIndex, index, ); } await tapBar('Food', 1, 'Nutrition & Hydration'); await tapBar('Plan', 2, 'Meal Planner'); await tapBar('Pantry', 3, 'Pantry & Shopping Delta'); await tapBar('Meds', 0, 'Medication Tracker'); await unmountAndFlushTimers(tester); }); testWidgets('mobile "More" tab opens the secondary-destinations drawer', (tester) async { await pumpApp( tester, viewport: const Size(390, 2400), extraOverrides: [devAuthBypassProvider.overrideWith((ref) => true)], ); // Open the drawer via the "More" bottom-bar tab. await tester.tap(find.text('More')); await tester.pumpAndSettle(); expect(find.byType(Drawer), findsOneWidget); // Exercise routes from the drawer and the "More" tab stays selected. await tester.tap(find.text('Exercise')); await tester.pumpAndSettle(); expect(find.text('Exercise Log'), findsOneWidget); expect( tester.widget(find.byType(NavigationBar)).selectedIndex, 4, ); // Recipes routes from the drawer as well. await tester.tap(find.text('More')); await tester.pumpAndSettle(); await tester.tap(find.text('Recipes')); await tester.pumpAndSettle(); expect(find.text('Recipe Vault'), findsOneWidget); // Settings routes from the drawer as well. await tester.tap(find.text('More')); await tester.pumpAndSettle(); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); expect(find.text('Identity'), findsOneWidget); expect(find.text('Targets'), findsOneWidget); expect(find.text('Security & Storage'), findsOneWidget); expect(find.text('Integrations'), findsOneWidget); await unmountAndFlushTimers(tester); }); testWidgets('unauthenticated sessions without bypass are bounced to /login', (tester) async { await pumpApp(tester); expect(find.byType(LoginScreen), findsOneWidget); expect(find.byType(NavigationRail), findsNothing); await unmountAndFlushTimers(tester); }); }