mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-12 08:30:51 +01:00
Four asks, plus the fixes two review passes turned up. Removals (all unused): - "Repeat last meal" button, and the reward/temptation-bundling prompt it was the sole trigger for. Replaced with a post-log progress card showing kcal against budget, macros, and the adherence streak. - The multi-item meal builder, on both the gate and the phone. The stored `components` field stays: nothing writes it now, but historical composite entries and the food bank still read it and it is part of the wire format. - Meal photos entirely: 13 files, `imagePath`, the photo filter, the wrapper's /blobs/ route, image_picker, and the CAMERA permission. Reminder false alarms had three real causes, all fixed: - Logging never reconciled notifications, so a reminder sat on screen up to 15 minutes after the meal was logged -- on the phone itself. - The background check never pulled, so a PC-logged meal nagged forever until the app was opened. Now mirrors _cli_gate._should_lock: cheap local check, network only when a slot looks due, then re-check. - Meals before 08:00 / after 22:00 were attributed to no slot at all. New slot_for_log/slotForLog clamps them to the first/last slot, byte-identical across Python and Dart. Deliberately separate from elapsed_slots, which would otherwise make every slot fall due at 23:00. Budget history, so lowering the budget stops rewriting the past: - Forward-only (effective_from, kcal) entries; each day is judged against the budget in force then. Verified live: setting 2000 left a 36-day adherence streak and the whole calendar pixel-identical. - Rides as hist:<date> fields on the existing budget record. crdt_sync merges per-field over the union of field names and both devices push the merged log, so a device that predates the feature relays them untouched -- no coordinated rollout, no second document. - The seed guard tests emptiness, not file presence: _sync can write an empty history after merging with a pre-feature peer, and a presence check would then never seed again, silently reclassifying every past day. One shared source of truth for every piece of data: - Body weight now travels as its own `weight` CRDT field. Inside `value` it was collateral damage of whole-map LWW -- any phone budget edit deleted it, and the protein target with it. - Both halves of the food bank sync. The curated half (foods added without eating them) was the last device-local state. The derived half merges with count as the clock, so LWW means max-count-wins; the log stays authoritative for membership, because a CRDT union never shrinks and a peer's stale copy would otherwise resurrect a deleted food permanently. - Bank adapters honour tombstones, and Dart parses bank records defensively: a cast failure there is an Error, not an Exception, so one malformed remote record would have broken phone sync permanently. _test_guard.py blocks any pathlib write to real user state while pytest is in the process, whatever conftest is loaded. A throwaway test run outside the package conftest destroyed a real GitHub token during this work; this makes that class of accident impossible rather than merely discouraged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
180 lines
5.7 KiB
Dart
180 lines
5.7 KiB
Dart
// Covers LogMealScreen's auto-sync: triggered on launch and on every
|
|
// AppLifecycleState change, best-effort/silent regardless of outcome.
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:diet_guard_app/services/document_store_io.dart';
|
|
import 'package:diet_guard_app/models/nutrition.dart';
|
|
import 'package:diet_guard_app/screens/log_meal_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';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.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_autosync_');
|
|
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(
|
|
'does not push when sync is unconfigured (defaults to off)',
|
|
(tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
installFakeSecureStorage();
|
|
var puts = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') puts++;
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: LogMealScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
expect(puts, 0);
|
|
});
|
|
},
|
|
);
|
|
|
|
testWidgets('pushes on launch when sync is configured', (tester) async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
var puts = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') puts++;
|
|
// A bare `/repos/<owner>/<repo>` GET is crdt_sync's GitHubClient
|
|
// probing whether the repo itself exists (vs. a content path just
|
|
// being unused) -- must succeed so an empty/unconfigured repo isn't
|
|
// mistaken for a missing one.
|
|
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: LogMealScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
expect(puts, 1);
|
|
});
|
|
});
|
|
|
|
testWidgets('pushes again when the app is paused', (tester) async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
var puts = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') puts++;
|
|
// A bare `/repos/<owner>/<repo>` GET is crdt_sync's GitHubClient
|
|
// probing whether the repo itself exists (vs. a content path just
|
|
// being unused) -- must succeed so an empty/unconfigured repo isn't
|
|
// mistaken for a missing one.
|
|
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: LogMealScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
expect(puts, 1); // launch
|
|
|
|
// Flutter's AppLifecycleListener enforces a strict transition graph
|
|
// (resumed -> inactive -> hidden -> paused -> ...); jumping straight
|
|
// from resumed to paused is the one direct transition it allows.
|
|
WidgetsBinding.instance.handleAppLifecycleStateChanged(
|
|
AppLifecycleState.paused,
|
|
);
|
|
await settle(tester);
|
|
expect(puts, 2);
|
|
});
|
|
});
|
|
|
|
testWidgets('swallows a sync failure without crashing the screen', (
|
|
tester,
|
|
) async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
final mock = MockClient((_) async => http.Response('boom', 500));
|
|
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: LogMealScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
expect(find.byType(LogMealScreen), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
});
|
|
|
|
testWidgets('pushes immediately after a meal is logged', (tester) async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
var puts = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') puts++;
|
|
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: LogMealScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
expect(puts, 1); // launch push
|
|
|
|
await tester.enterText(find.byType(TextField).at(0), 'push-on-log');
|
|
await tester.pump();
|
|
await tester.tap(find.byTooltip('Log meal'));
|
|
await settle(tester);
|
|
|
|
// The new meal is pushed right away, not left for a lifecycle event.
|
|
expect(puts, 2);
|
|
});
|
|
});
|
|
}
|