mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +01:00
The phone now prefills the log form with today's delivered dishes, in the caterer's own meal order. It still never logs anything itself -- a delivered meal is not an eaten meal, so the user taps for each dish. Two guards carried over from the PC, both of which fail silently if dropped: - **The queue survives a submit.** `prefillNextDish` has two callers: the initial load and the post-submit advance. On the PC the second briefly did not exist, which made "(N more to go)" a dead letter -- every dish after the first waited behind another tap. The test asserts the *form controller* holds dish 2 after dish 1 is logged, not that a dish is "available"; the latter passes throughout that bug. - **The automatic refresh is guarded** by a device-local last-fetched date, mirroring `refresh_delivery_once`. The log-flow trigger can fire after every meal and each unguarded walk is a login plus three requests. The settings button deliberately stays unguarded: an explicit ask always goes and looks, matching `diet-guard kuchnia`. Only a clean fetch records the day, so an outage retries. Dishes are ordered against `MealScheduleService.current`, never a hardcoded four slots -- offering a dish for a slot the user's schedule does not have is the "checkpoint that can never be satisfied" failure in a new costume. Editing the description before submitting leaves the dish queued: what was logged was not the delivered dish, and marking it eaten would lose a real delivery silently. Two bugs the tests caught: - `dishesStillQueued` excluded the offered dish, so the status line read "0 more" while a real dish sat in the form. The offered dish is prefilled, not logged; it counts. - An end-to-end test through the real `LogMealScreen` never terminated -- that screen's `initState` fans out into sync, notifications and the food bank, and pumping it to quiescence hangs. Rather than keep fighting it, the behaviour is tested at the mixin that owns it, through a minimal host. The guarantee asserted is the same one: form contents after a submit. `log_meal_screen.dart` is back under the 250-line cap by moving the nutrition build into `log_meal_actions.dart` and inlining a one-line alias. 694 Dart tests, 1040 Python tests, 100% Python branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
152 lines
5.5 KiB
Dart
152 lines
5.5 KiB
Dart
/// The delivered-dish queue and its refresh guard.
|
|
///
|
|
/// Two failure modes here are invisible to an "is the dish offered?" test,
|
|
/// which is why these assert on *counts*:
|
|
///
|
|
/// * **The queue must survive a submit.** On the PC this regressed into a dead
|
|
/// letter once: every dish after the first stayed queued behind another
|
|
/// click. Asserting that a dish was offered passes while that misbehaves;
|
|
/// asserting that dish 2 is next *immediately after* dish 1 is logged does
|
|
/// not.
|
|
/// * **The automatic refresh must be guarded.** Each refresh is a login plus a
|
|
/// three-request walk against a third party, and the log-flow trigger can
|
|
/// fire many times a day. The test counts fetches, not results.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/models/kuchnia_dish.dart';
|
|
import 'package:diet_guard_app/services/document_store.dart';
|
|
import 'package:diet_guard_app/services/kuchnia_queue.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// In-memory store, so these tests need no plugin channel.
|
|
class _MemoryStore implements DocumentStore {
|
|
final Map<String, String> documents = {};
|
|
|
|
@override
|
|
Future<String?> read(String name) async => documents[name];
|
|
|
|
@override
|
|
Future<void> write(String name, String contents) async {
|
|
documents[name] = contents;
|
|
}
|
|
}
|
|
|
|
KuchniaDish _dish(String name, int priority) => KuchniaDish(
|
|
name: name,
|
|
kcal: 400,
|
|
proteinG: 25,
|
|
carbsG: 45,
|
|
fatG: 12,
|
|
grams: 300,
|
|
priority: priority,
|
|
slotLabel: 'Obiad',
|
|
);
|
|
|
|
void main() {
|
|
late _MemoryStore store;
|
|
final today = DateTime(2026, 8, 23);
|
|
|
|
setUp(() async {
|
|
store = _MemoryStore();
|
|
await KuchniaQueueService.initForTesting(store);
|
|
});
|
|
|
|
tearDown(KuchniaQueueService.resetForTesting);
|
|
|
|
group('the queue survives a submit', () {
|
|
test('logging dish 1 leaves dish 2 as the next offer', () {
|
|
final dishes = [_dish('Pancakes', 1), _dish('Kaszotto', 2)];
|
|
KuchniaQueueService.instance.offer(dishes);
|
|
expect(KuchniaQueueService.next!.name, 'Pancakes');
|
|
expect(KuchniaQueueService.remaining, 2);
|
|
|
|
KuchniaQueueService.instance.markLogged(dishes.first);
|
|
|
|
// The whole delivery must walk on one tap per dish. If this ever reads
|
|
// null, the "(N more to go)" promise is a dead letter again.
|
|
expect(KuchniaQueueService.next!.name, 'Kaszotto');
|
|
expect(KuchniaQueueService.remaining, 1);
|
|
});
|
|
|
|
test('the last dish empties the queue', () {
|
|
final dish = _dish('Pancakes', 1);
|
|
KuchniaQueueService.instance.offer([dish]);
|
|
KuchniaQueueService.instance.markLogged(dish);
|
|
expect(KuchniaQueueService.next, isNull);
|
|
expect(KuchniaQueueService.remaining, 0);
|
|
});
|
|
|
|
test('a dish left over when the last slot unlocks is still queued', () {
|
|
// Five delivered meals against the default four slots means one dish is
|
|
// still pending when the last slot is logged. It must not vanish -- it
|
|
// is already banked and the user can still log it.
|
|
final dishes = [for (var i = 1; i <= 5; i++) _dish('Dish $i', i)];
|
|
KuchniaQueueService.instance.offer(dishes);
|
|
for (final dish in dishes.take(4)) {
|
|
KuchniaQueueService.instance.markLogged(dish);
|
|
}
|
|
expect(KuchniaQueueService.remaining, 1);
|
|
expect(KuchniaQueueService.next!.name, 'Dish 5');
|
|
});
|
|
|
|
test('dishes already logged today are not re-offered', () {
|
|
KuchniaQueueService.instance.offer(
|
|
[_dish('Pancakes', 1), _dish('Kaszotto', 2)],
|
|
alreadyLogged: {'pancakes'},
|
|
);
|
|
expect(KuchniaQueueService.remaining, 1);
|
|
expect(KuchniaQueueService.next!.name, 'Kaszotto');
|
|
});
|
|
|
|
test('clearing empties the queue', () {
|
|
KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]);
|
|
KuchniaQueueService.instance.clear();
|
|
expect(KuchniaQueueService.remaining, 0);
|
|
});
|
|
});
|
|
|
|
group('the refresh guard', () {
|
|
test('a day is unfetched until it is recorded', () async {
|
|
expect(KuchniaQueueService.instance.alreadyFetched(today), isFalse);
|
|
await KuchniaQueueService.instance.recordFetched(today);
|
|
expect(KuchniaQueueService.instance.alreadyFetched(today), isTrue);
|
|
});
|
|
|
|
test('the marker is per day, not a flag', () async {
|
|
await KuchniaQueueService.instance.recordFetched(today);
|
|
expect(
|
|
KuchniaQueueService.instance.alreadyFetched(DateTime(2026, 8, 24)),
|
|
isFalse,
|
|
reason: 'tomorrow has its own delivery; the marker is one ISO date',
|
|
);
|
|
});
|
|
|
|
test('the marker survives a reload', () async {
|
|
await KuchniaQueueService.instance.recordFetched(today);
|
|
KuchniaQueueService.resetForTesting();
|
|
await KuchniaQueueService.initForTesting(store);
|
|
expect(KuchniaQueueService.instance.alreadyFetched(today), isTrue);
|
|
});
|
|
|
|
test('refreshOnce skips the walk once the day is recorded', () async {
|
|
// The guard's whole job: the log-flow trigger fires after every meal,
|
|
// and each unguarded refresh is a login plus three requests.
|
|
await KuchniaQueueService.instance.recordFetched(today);
|
|
final result = await KuchniaQueueService.refreshOnce(today);
|
|
expect(
|
|
result.ok,
|
|
isTrue,
|
|
reason: 'an already-fetched day is "nothing new", not an error',
|
|
);
|
|
expect(result.dishes, isEmpty);
|
|
});
|
|
|
|
test('refreshOnce is a no-op before the service is initialised', () async {
|
|
KuchniaQueueService.resetForTesting();
|
|
final result = await KuchniaQueueService.refreshOnce(today);
|
|
expect(result.ok, isTrue);
|
|
expect(result.dishes, isEmpty);
|
|
});
|
|
});
|
|
}
|