diet-guard/app/test/screens/edit_entry_screen_test.dart
Krzysztof kuhy Rudnicki 802ee0fe6d app: run the companion app's desktop target as a web build
Flutter's Linux embedder manages ~20fps at 3840x2160 -- measured on a stock
`flutter create` counter app, so it is the toolkit rather than this code --
where the same Dart rendered by Chrome sustains ~144fps (evidence in
~/todo/docs/desktop-performance-findings.md). The desktop target therefore
becomes a Flutter *web* build; Android is untouched.

`dart:io` does not fail a web *compile* -- it resolves to a stub that throws
at runtime -- so every platform edge moves behind a conditional export rather
than being discovered later as a blank white window:

- storage: `document_store*.dart`, files on Android and IndexedDB in the
  browser (mirrored to the desktop wrapper, which is what makes a wiped
  Chrome profile recoverable). Document names stay the exact pre-web
  filenames so an installed phone keeps reading its existing log.
- photos: `blob_store*.dart` plus `attached_image*.dart`, since `Image.file`
  cannot resolve an IndexedDB key. The picker itself has web support; what
  changed is the storage and display model. Camera *capture* is genuinely
  lost on desktop -- and photos never crossed devices anyway, `imagePath`
  being stripped before push.
- scheduling: `background_tasks*.dart`. A browser has no WorkManager
  equivalent -- a closed tab runs nothing -- so the desktop runs an in-page
  5-minute check while its window is open. That loses nothing on the PC,
  where `diet-guard-gate.timer` already locks the screen on an unlogged
  slot, which is strictly stronger than a notification.
- notifications: `notification_backend*.dart`, the browser Notifications API
  in place of flutter_local_notifications.
- the GitHub token: `token_vault*.dart`. On web it is *not* held by the
  browser at all -- flutter_secure_storage's web backend is WebCrypto-wrapped
  localStorage with the wrap key beside the ciphertext, which is obfuscation,
  not encryption at rest -- so the desktop wrapper owns it and proxies the
  API (`github_client_factory*.dart`).

`Platform.isAndroid` itself throws on web, so every platform branch now tests
`kIsWeb` first; the battery-optimization row (permission_handler, no web
implementation) is hidden off Android.

The desktop pushes under device id `desktop`, not `phone`: sharing an id
would have made the two devices overwrite each other's pushed log on every
tick. The Python side enumerates device directories generically, so it needs
no change.

Tests move onto the same seams -- the file-backed stores keep the atomicity
and unreadable-file guarantees where they belong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:31:32 +02:00

270 lines
8.0 KiB
Dart

import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/edit_entry_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
const _entry = FoodEntry(
id: 'test-uuid',
time: '2026-06-22T12:00:00+02:00',
desc: 'pizza obok pracy',
grams: 400,
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
source: 'manual',
);
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_edit_test_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
await tempDir.delete(recursive: true);
});
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('pre-fills all fields from the entry', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
expect(find.text('pizza obok pracy'), findsOneWidget);
expect(find.text('1200'), findsOneWidget);
expect(find.text('52'), findsOneWidget);
expect(find.text('145'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.text('400'), findsOneWidget);
});
});
testWidgets('Save button is present and AppBar title is correct', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('Save'), findsOneWidget);
});
});
testWidgets('shows error when description is cleared', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Clear the description field.
final descField = find.ancestor(
of: find.text('pizza obok pracy'),
matching: find.byType(TextField),
);
await tester.enterText(descField, '');
await tester.tap(find.text('Save'));
await settle(tester);
expect(find.text('Description cannot be empty.'), findsOneWidget);
});
});
testWidgets('Save persists updated kcal to the log', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Change kcal.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
'999',
);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
final saved = log['2026-06-22']!.first;
expect(saved.kcal, 999);
expect(saved.desc, 'pizza obok pracy');
expect(saved.id, 'test-uuid');
});
});
testWidgets('legacy null-id entry gains a UUID on save', (tester) async {
await tester.runAsync(() async {
const legacy = FoodEntry(
time: '2026-06-22T08:00:00+02:00',
desc: 'kabanosy',
grams: 380,
kcal: 1174,
proteinG: 53,
carbsG: 19,
fatG: 152,
source: 'food bank',
);
await LogStorageService.instance.writeLog({
'2026-06-22': [legacy],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: legacy)),
);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
final saved = log['2026-06-22']!.first;
expect(saved.id, isNotNull);
expect(saved.id, isNotEmpty);
expect(saved.kcal, 1174);
});
});
testWidgets('selecting a food bank suggestion fills all macro fields', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// The desc field already matches the bank entry — a suggestion appears.
expect(find.text('pizza obok pracy'), findsWidgets);
// Tap the suggestion tile (the one in the autocomplete list, not the
// TextField itself).
final suggestionTiles = find.text('pizza obok pracy');
// At least 2 matches: TextField text + suggestion tile.
expect(suggestionTiles, findsWidgets);
await tester.tap(suggestionTiles.last);
await settle(tester);
// After selection, suggestions are cleared and macros are filled.
expect(find.text('1200'), findsOneWidget);
});
});
testWidgets(
'editing a macro after selecting a suggestion resets source to manual',
(tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Select the suggestion to set _source = 'food bank'.
final suggestionTiles = find.text('pizza obok pracy');
await tester.tap(suggestionTiles.last);
await settle(tester);
// Manually edit kcal — should flip _source back to 'manual'.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
'999',
);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
// Source reverts to 'manual' after macro edit.
expect(log['2026-06-22']!.first.source, 'manual');
});
},
);
testWidgets('tapping an entry tile navigates to EditEntryScreen', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.text('pizza obok pracy'));
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('pizza obok pracy'), findsOneWidget);
// Macros are pre-filled.
expect(find.text('1200'), findsOneWidget);
});
});
}