diet-guard/app/test/screens/settings_screen_test.dart
Krzysztof kuhy Rudnicki cb8028bd22 app settings: exact-time clock button replaces the "Custom…" item
The dropdown menu opens centred on the current value, so a "Custom…" item
at either end of ~95 entries was always a long scroll away. Each meal-time
field now has a clock IconButton ("Type or pick an exact time") opening the
same 24h showTimePicker with the same clamping. The off-grid current value
is still offered as an item.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 20:10:55 +02:00

250 lines
9.2 KiB
Dart

import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/firebase_backend.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_secure_storage.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_settings_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
await MealScheduleService.initForTesting(FileDocumentStore(tempDir));
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
MealScheduleService.resetForTesting();
await tempDir.delete(recursive: true);
});
// SettingsScreen loads its settings via real (non-fake-clocked) I/O
// through FileDocumentStore, so every test body runs under
// tester.runAsync() -- without it, those Futures never get a chance to
// complete inside TestWidgetsFlutterBinding's fake-clocked zone, and
// pumpAndSettle spins forever. Also grows the test viewport: the
// Notifications section pushes earlier fields/buttons below the default
// 800x600 fold, making them unreachable to tester.tap otherwise.
Future<void> settle(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('shows the current kcal goal on load', (tester) async {
await tester.runAsync(() async {
await AppSettingsService.instance.saveDailyKcalGoal(1800);
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
expect(find.widgetWithText(TextField, '1800'), findsOneWidget);
});
});
testWidgets('shows the derived meal times for the stored schedule', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
// The default schedule still derives the hours that used to be
// hardcoded, so an existing install sees no change.
expect(
find.text('08:00 · 12:00 · 16:00 · 20:00'),
findsOneWidget,
);
expect(find.text('First meal'), findsOneWidget);
expect(find.text('Last meal'), findsOneWidget);
expect(find.text('Meals per day'), findsOneWidget);
});
});
testWidgets('an exact 07:23 first meal saves the schedule', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.tap(find.byTooltip('Type or pick an exact time').first);
await settle(tester);
await tester.tap(find.byIcon(Icons.keyboard_outlined));
await settle(tester);
final fields = find.descendant(
of: find.byType(TimePickerDialog),
matching: find.byType(TextField),
);
await tester.enterText(fields.at(0), '07');
await tester.enterText(fields.at(1), '23');
await tester.tap(find.text('OK'));
await settle(tester);
// Through the real _applySchedule: normalised, recorded, persisted.
expect(MealScheduleService.current.firstMinute, 443);
expect(find.text('07:23 · 11:30 · 15:45 · 20:00'), findsOneWidget);
});
});
testWidgets('reflects a five-meal schedule as the user\'s example', (
tester,
) async {
await tester.runAsync(() async {
await MealScheduleService.instance.recordChange(
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
);
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
expect(
find.text('08:00 · 11:00 · 14:00 · 17:00 · 20:00'),
findsOneWidget,
);
});
});
testWidgets('typing a kcal goal debounces the save', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Daily kcal goal'),
'2100',
);
// Immediately after typing, the debounce has not fired yet.
expect(AppSettingsService.dailyKcalGoal, isNot(2100));
await Future<void>.delayed(const Duration(milliseconds: 700));
await tester.pump();
expect(AppSettingsService.dailyKcalGoal, 2100);
});
});
testWidgets('an invalid kcal goal is not saved', (tester) async {
await tester.runAsync(() async {
final initial = AppSettingsService.dailyKcalGoal;
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Daily kcal goal'),
'0',
);
await Future<void>.delayed(const Duration(milliseconds: 700));
await tester.pump();
expect(AppSettingsService.dailyKcalGoal, initial);
});
});
testWidgets('disposing mid-debounce still flushes the pending goal', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Daily kcal goal'),
'1950',
);
// Leave before the 600ms debounce fires. dispose()'s flush fires the
// persist unawaited (the framework cannot await dispose()), so the
// in-memory value updates synchronously but the disk write is still a
// real pending Future -- give it a moment to finish before tearDown
// deletes the temp dir out from under it.
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
await tester.pump();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(AppSettingsService.dailyKcalGoal, 1950);
});
});
testWidgets('tapping Sync settings opens the shared SyncSettingsScreen', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
accountLoader: () async => null,
sessionProbe: () async => false,
googleAvailable: false,
),
),
);
await settle(tester);
await tester.tap(find.text('Sync settings'));
await settle(tester);
expect(find.text('Firebase sync'), findsOneWidget);
});
});
testWidgets(
'Sync settings wires storedAccount, not loadAccount, as the '
'default accountLoader',
(tester) async {
// Verifies the read-back fix documented on SettingsScreen.accountLoader:
// on a device with a stored account and a live session, the shared
// screen must show it as connected using the injected default, with no
// fake required to prove the wiring compiles and runs end-to-end.
await tester.runAsync(() async {
await saveAccount(
const FirebaseAccount(email: 'sync@example.com', password: 'pw'),
);
await credentialStore().save(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
),
);
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.tap(find.text('Sync settings'));
await settle(tester);
expect(find.text('sync@example.com'), findsOneWidget);
expect(find.text('Disconnect'), findsOneWidget);
});
},
);
testWidgets(
'tapping Advanced sync (GitHub) opens the local GitHubMirrorScreen',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.tap(find.text('Advanced sync (GitHub)'));
await settle(tester);
expect(find.text('Advanced sync (GitHub)'), findsWidgets);
expect(find.text('Connect GitHub'), findsOneWidget);
});
},
);
}