glp-companion/test/integration/app_navigation_test.dart

273 lines
8.7 KiB
Dart

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<void> initialize() async {}
@override
Future<bool> requestPermissions() async => false;
@override
Future<bool> scheduleDoseReminder({
required DateTime scheduledAt,
required String title,
required String body,
int id = kDoseReminderNotificationId,
String? payload,
}) async =>
false;
@override
Future<void> cancelReminder(int id) async {}
@override
Future<void> 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<Override> baseOverrides() => [
appDatabaseProvider.overrideWithValue(database),
notificationServiceProvider
.overrideWithValue(_NoopNotificationService()),
];
Future<void> pumpApp(
WidgetTester tester, {
Size viewport = const Size(1280, 1400),
List<Override> 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<void> 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<void> 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<NavigationRail>(find.byType(NavigationRail)).selectedIndex,
index,
);
}
Future<void> 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<void> 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<NavigationBar>(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<NavigationBar>(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);
});
}