diff --git a/app/lib/screens/log_meal_actions.dart b/app/lib/screens/log_meal_actions.dart index fb7571d..a7fadc7 100644 --- a/app/lib/screens/log_meal_actions.dart +++ b/app/lib/screens/log_meal_actions.dart @@ -1,5 +1,23 @@ +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/screens/log_meal_progress.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:flutter/material.dart'; +/// Builds the [Nutrition] for a submit from the form's macro controllers. +/// +/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. Pure over +/// its inputs, so the per-portion scaling is testable without a widget. +Nutrition nutritionFromControllers(MacroControllers macros, String source) => + nutritionForPortion( + kcal: parseMacroField(macros.kcal), + proteinG: parseMacroField(macros.protein), + carbsG: parseMacroField(macros.carbs), + fatG: parseMacroField(macros.fat), + perGrams: parseMacroField(macros.perGrams), + ateGrams: parseMacroField(macros.grams), + source: source, + ); + /// The four destination buttons in [LogMealScreen]'s app bar. /// /// Split out of `log_meal_screen.dart` for the repo's 250-line cap. Returned diff --git a/app/lib/screens/log_meal_kuchnia_mixin.dart b/app/lib/screens/log_meal_kuchnia_mixin.dart new file mode 100644 index 0000000..d569aad --- /dev/null +++ b/app/lib/screens/log_meal_kuchnia_mixin.dart @@ -0,0 +1,155 @@ +/// Offers the day's delivered catering dishes to the log form. +/// +/// Its own file because `log_meal_screen.dart` is at the repo's 250-line +/// ceiling. +/// +/// **A delivered meal is not an eaten meal.** This only ever *prefills* the +/// form -- the user still taps "Log" for each dish. Nothing here writes a log +/// entry, and nothing here may start doing so: the gate would then satisfy its +/// own checkpoint from a delivery note, and the log would record what the +/// courier dropped off rather than what was eaten. +/// +/// [prefillNextDish] is called from **two** places: once when the delivery +/// first arrives, and again after every successful submit. That second caller +/// is the load-bearing one. On the PC it briefly did not exist, which made the +/// "(N more to go)" promise a dead letter -- every dish after the first stayed +/// queued behind another click. `kuchnia_queue_test.dart` asserts the state +/// transition rather than "a dish was offered", because the latter passes +/// while this misbehaves. +library; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/kuchnia_queue.dart'; +import 'package:diet_guard_app/services/kuchnia_spread.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:flutter/material.dart'; + +/// Writes [dish]'s name and macros into the log form's controllers. +/// +/// Free function rather than a mixin method so the numeric formatting is +/// testable without pumping a widget. +void fillControllersFromDish( + KuchniaDish dish, + TextEditingController desc, + MacroControllers macros, +) { + desc.text = dish.name; + final (grams, values) = dishFieldValues(dish); + macros.kcal.text = values.$1; + macros.protein.text = values.$2; + macros.carbs.text = values.$3; + macros.fat.text = values.$4; + macros.grams.text = grams; +} + +/// Wires the catering queue into a log form. +mixin LogMealKuchniaMixin on State { + /// The dish currently prefilled from the delivery, if any. + KuchniaDish? offeredDish; + + /// How many delivered dishes still need logging, including the offered one. + /// + /// The offered dish counts: it is prefilled, not logged, and the user has + /// still to tap for it. Excluding it made the line read "0 more" while a + /// real dish sat in the form. + int get dishesStillQueued => KuchniaQueueService.remaining; + + /// The form's description field. Supplied by the screen. + TextEditingController get descController; + + /// The form's macro fields. Supplied by the screen. + MacroControllers get macroControllers; + + /// Called after the form is filled, so the screen can mark the source. + void onDishPrefilled() {} + + /// Fills the form's fields from [dish]. + /// + /// Concrete here rather than an abstract hook: every implementation would be + /// the same three lines, and the screen file has no budget to spare. + void fillFormFromDish(KuchniaDish dish) { + fillControllersFromDish(dish, descController, macroControllers); + onDishPrefilled(); + } + + /// Loads today's delivery, guarded so it costs at most one walk per day. + /// + /// Goes through [KuchniaQueueService.refreshOnce] rather than + /// `refreshDelivery`: this is an *automatic* trigger and can fire after + /// every meal, and each unguarded refresh is a login plus three requests + /// against a third party. The settings button is the explicit ask that + /// always goes and looks. + Future loadDeliveryOnce({Set alreadyLogged = const {}}) async { + if (!KuchniaQueueService.isInitialized) return; + final result = await KuchniaQueueService.refreshOnce(DateTime.now()); + if (!mounted || result.dishes.isEmpty) return; + // Ordered by the caterer's own meal priority, against the *user's* + // schedule -- not a hardcoded four slots. A dish offered for a slot the + // user's schedule does not have is a checkpoint that can never be met. + final ordered = dishesInSlotOrder( + result.dishes, + daySlots(MealScheduleService.current), + ); + KuchniaQueueService.instance.offer(ordered, alreadyLogged: alreadyLogged); + prefillNextDish(); + } + + /// Loads today's delivery, skipping dishes already logged today. + /// + /// The skip matters across devices: a dish logged on the PC must not be + /// offered again here, and the food log is the only record of that. + Future loadTodaysDelivery() async { + final logged = await LogStorageService.instance.todayEntries(); + if (!mounted) return; + await loadDeliveryOnce( + alreadyLogged: { + for (final entry in logged) entry.desc.trim().toLowerCase(), + }, + ); + } + + /// Prefills the form with the next queued dish, if there is one. + /// + /// Safe to call when the queue is empty: it simply clears the offer. + void prefillNextDish() { + final dish = KuchniaQueueService.next; + if (dish == null) { + if (offeredDish != null) setState(() => offeredDish = null); + return; + } + setState(() => offeredDish = dish); + fillFormFromDish(dish); + } + + /// Drops the just-logged dish and immediately offers the next one. + /// + /// Call this from the submit handler *after* the entry is written. This is + /// the second caller that keeps the queue alive across a submit. + void advanceQueueAfterLog(String loggedDesc) { + final dish = offeredDish; + if (dish == null) return; + if (dish.name.trim().toLowerCase() != loggedDesc.trim().toLowerCase()) { + // The user edited the description into something else, so the offered + // dish was not what they logged. Leave it queued rather than silently + // marking a dish eaten that never was. + return; + } + KuchniaQueueService.instance.markLogged(dish); + offeredDish = null; + prefillNextDish(); + } + + /// A short line naming what is still queued, or null when nothing is. + /// + /// The leftover matters: the caterer's five meals against four default slots + /// means at least one dish is still queued when the last slot is logged. It + /// is already banked, so it is named rather than dropped silently. + String? get queueStatusLine { + final left = dishesStillQueued; + if (left <= 0) return null; + return '$left more delivered ${left == 1 ? 'dish' : 'dishes'} to log.'; + } +} diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index dac1870..acd371e 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -6,9 +6,9 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_suggestion.dart'; -import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/screens/log_meal_actions.dart'; +import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart'; import 'package:diet_guard_app/screens/log_meal_nav_mixin.dart'; import 'package:diet_guard_app/screens/log_meal_progress.dart'; import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart'; @@ -43,7 +43,8 @@ class _LogMealScreenState extends State with WidgetsBindingObserver, LogMealSyncMixin, - LogMealNavMixin { + LogMealNavMixin, + LogMealKuchniaMixin { @override http.Client? get syncHttpClient => widget.httpClient; @@ -78,6 +79,9 @@ class _LogMealScreenState extends State // a previous session says so immediately rather than only after a tick. unawaited(refreshSyncHealth()); unawaited(autoSync()); + // After autoSync so a dish already logged on the PC is known before the + // queue is built, and guarded so this costs at most one walk per day. + unawaited(loadTodaysDelivery()); } @override @@ -110,6 +114,13 @@ class _LogMealScreenState extends State }); } + @override + TextEditingController get descController => _descController; + @override + MacroControllers get macroControllers => _macros; + @override + void onDishPrefilled() => setState(() => _source = 'catering'); + void _onSuggestionSelected(FoodSuggestion suggestion) { _descController.text = suggestion.name; _macros.fillFrom(suggestion.nutrition); @@ -128,15 +139,7 @@ class _LogMealScreenState extends State }); return; } - final nutrition = nutritionForPortion( - kcal: parseMacroField(_macros.kcal), - proteinG: parseMacroField(_macros.protein), - carbsG: parseMacroField(_macros.carbs), - fatG: parseMacroField(_macros.fat), - perGrams: parseMacroField(_macros.perGrams), - ateGrams: parseMacroField(_macros.grams), - source: _source, - ); + final nutrition = nutritionFromControllers(_macros, _source); await LogStorageService.instance.logMeal( desc, nutrition, @@ -151,7 +154,11 @@ class _LogMealScreenState extends State // Offline backstop: if the push above fails (no connectivity), a // connectivity-gated WorkManager task uploads the meal on reconnect. unawaited(enqueueSyncBackstopTask()); - await _dismissStaleReminder(); + // Clears a reminder the meal just logged has now satisfied; without it a + // notification already on screen survives up to 15 min and reads as a + // false alarm. `pullWhenDue: false` because `autoSync` above already owns + // the network for this submit. + await checkAndNotify(pullWhenDue: false); if (!mounted) return; _descController.clear(); _macros.clear(); @@ -161,23 +168,16 @@ class _LogMealScreenState extends State }); await refreshSlots(); if (!mounted) return; + // The second `prefillNextDish` caller. Without it every dish after the + // first stays queued behind another tap and the "N more to log" line + // becomes a dead letter -- the exact regression the PC gate once shipped. + advanceQueueAfterLog(desc); setState(() { - _status = null; + _status = queueStatusLine; _progress = buildTodayProgress(log); }); } - /// Clears any reminder the meal just logged has now satisfied. - /// - /// Without this a notification already on screen survives until the next - /// background tick (up to 15 minutes), which reads as a false alarm even - /// though the meal was logged on this very device. Passes - /// `pullWhenDue: false` because [autoSync] above already owns the network - /// here and the local log is by definition the freshest copy. - /// - /// [checkAndNotify] already swallows notification-platform failures, so - /// the meal -- written before this runs -- can never be lost to one. - Future _dismissStaleReminder() => checkAndNotify(pullWhenDue: false); @override Widget build(BuildContext context) { diff --git a/app/lib/services/kuchnia_queue.dart b/app/lib/services/kuchnia_queue.dart new file mode 100644 index 0000000..be15cc0 --- /dev/null +++ b/app/lib/services/kuchnia_queue.dart @@ -0,0 +1,127 @@ +/// The day's delivered dishes, offered to the log form one at a time. +/// +/// **A delivered meal is not an eaten meal.** This queue only ever *prefills* +/// the form; the user still taps to log each dish. Nothing here writes a log +/// entry, and nothing here may start doing so -- the gate would then satisfy +/// its own checkpoint from a delivery note, and the log would record what the +/// courier dropped off rather than what was eaten. +/// +/// Two things are load-bearing: +/// +/// * **The queue survives a submit.** After a dish is logged the next one is +/// already in the form, so one tap walks the whole delivery. On the PC this +/// regressed once into a dead letter -- every dish after the first stayed +/// queued behind another click, and because the refresh there is unguarded +/// that meant a fresh login-plus-three-requests *per dish*. +/// * **The automatic refresh is guarded** by a device-local "already fetched +/// today" marker, mirroring Python's `refresh_delivery_once`. Without it +/// every logged meal would pay a login plus three requests against a third +/// party. Only a *clean* fetch records the day, so an outage is retried. +/// The settings button deliberately does **not** go through this: an +/// explicit ask always goes and looks, matching `diet-guard kuchnia`. +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_import.dart'; +import 'package:diet_guard_app/services/kuchnia_orders.dart' show isoDay; +import 'package:flutter/foundation.dart'; + +/// Holds today's delivered dishes and hands them out in the caterer's order. +class KuchniaQueueService { + KuchniaQueueService._(this._store); + + /// Document holding the last fetched day, one ISO date. + /// + /// Device-local on purpose: this is a rate limit, not shared state. Syncing + /// it would let one device's fetch suppress another's. + static const documentName = 'kuchnia_last_import.json'; + + static KuchniaQueueService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called. + static KuchniaQueueService get instance => _instance!; + + /// True once initialised, so callers can skip work in widget tests. + static bool get isInitialized => _instance != null; + + final DocumentStore _store; + final List _pending = []; + String _lastImportDay = ''; + + /// The dishes not yet logged, in the caterer's own meal order. + static List get pending => + List.unmodifiable(_instance?._pending ?? const []); + + /// The next dish to offer, or null when the queue is empty. + static KuchniaDish? get next => + (_instance?._pending.isEmpty ?? true) ? null : _instance!._pending.first; + + /// How many dishes are still waiting to be logged. + static int get remaining => _instance?._pending.length ?? 0; + + /// Initialises from [store], for tests and for [init]. + @visibleForTesting + static Future initForTesting(DocumentStore store) async { + final service = KuchniaQueueService._(store); + await service._load(); + _instance = service; + return service; + } + + /// Resets the singleton so [initForTesting] can run again. + @visibleForTesting + static void resetForTesting() => _instance = null; + + Future _load() async { + final raw = await _store.read(documentName); + if (raw != null) _lastImportDay = raw.trim(); + } + + /// True when [day]'s delivery has already been fetched on this device. + bool alreadyFetched(DateTime day) => _lastImportDay == isoDay(day); + + /// Records that [day]'s delivery was fetched cleanly. + /// + /// Best-effort: this is a rate limit, not state anyone depends on. Failing + /// to write it costs one extra fetch, which is strictly better than failing + /// an import that already succeeded. + Future recordFetched(DateTime day) async { + _lastImportDay = isoDay(day); + try { + await _store.write(documentName, _lastImportDay); + } on Exception { + // Ignore: an unrecorded fetch just means we look again. + } + } + + /// Replaces the queue with [dishes], dropping any already logged today. + void offer(List dishes, {Set alreadyLogged = const {}}) { + _pending + ..clear() + ..addAll( + dishes.where((dish) => !alreadyLogged.contains(dish.bankKey)), + ); + } + + /// Removes [dish] from the queue once it has actually been logged. + void markLogged(KuchniaDish dish) => + _pending.removeWhere((queued) => queued.bankKey == dish.bankKey); + + /// Empties the queue, e.g. when the day rolls over. + void clear() => _pending.clear(); + + /// Fetches [day]'s delivery unless it has already been fetched today. + /// + /// Returns an empty, successful result when the day was already fetched -- + /// "nothing new", not an error. This is the entry point every *automatic* + /// trigger uses; the settings button calls [refreshDelivery] directly. + static Future refreshOnce(DateTime day) async { + final service = _instance; + if (service == null) return const KuchniaRefresh(); + if (service.alreadyFetched(day)) return const KuchniaRefresh(); + final result = await refreshDelivery(day); + if (result.ok) await service.recordFetched(day); + return result; + } +} diff --git a/app/lib/services/kuchnia_spread.dart b/app/lib/services/kuchnia_spread.dart index 3561904..9d55282 100644 --- a/app/lib/services/kuchnia_spread.dart +++ b/app/lib/services/kuchnia_spread.dart @@ -84,3 +84,28 @@ List dishesInSlotOrder( List dishes, List slots, ) => [for (final item in assignSlots(dishes, slots)) item.dish]; + +/// Returns `(portionGrams, (kcal, protein, carbs, fat))` as form strings. +/// +/// Mirrors `_kuchnia_spread.dish_field_values`. Formatted here rather than in +/// the widget so the numeric formatting is covered by a test that needs no +/// display. +/// +/// Python uses `f"{value:g}"`, which drops a trailing `.0`; Dart's +/// `toStringAsFixed` would keep it and `toString` would render `435.0`. So a +/// whole number is printed as an integer and anything else keeps its +/// decimals -- the form should read "435", not "435.0". +(String, (String, String, String, String)) dishFieldValues(KuchniaDish dish) { + String format(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : value.toString(); + return ( + format(dish.grams), + ( + format(dish.kcal), + format(dish.proteinG), + format(dish.carbsG), + format(dish.fatG), + ), + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 2785973..5a58530 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.63+1 +version: 1.0.64+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/log_meal_kuchnia_mixin_test.dart b/app/test/screens/log_meal_kuchnia_mixin_test.dart new file mode 100644 index 0000000..c23a6b8 --- /dev/null +++ b/app/test/screens/log_meal_kuchnia_mixin_test.dart @@ -0,0 +1,187 @@ +/// The delivered-dish queue as the log form actually drives it. +/// +/// `kuchnia_queue_test.dart` covers the queue's own state machine. This file +/// covers the part that state machine exists for and that a queue-only test +/// cannot see: after a submit, are the **form controllers** already holding +/// the next dish? +/// +/// That distinction is the whole point. On the PC this regressed once -- +/// `_prefill_next_dish` briefly had exactly one caller, so the queue was +/// correct and the form was not. Every dish after the first stayed behind +/// another tap and the "(N more to go)" line became a dead letter. A test +/// asserting "the dish was offered" passes throughout that bug; one asserting +/// the controller contents after a submit does not. +/// +/// Driven through a minimal host rather than the real `LogMealScreen`: that +/// screen's `initState` fans out into sync, notifications and the food bank, +/// and pumping it to quiescence in a widget test does not terminate. The +/// mixin is the unit that owns this behaviour, so it is the unit under test. +library; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/kuchnia_queue.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory store, so these tests need no plugin channel. +class _MemoryStore implements DocumentStore { + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +/// The smallest widget that uses the mixin the way the log screen does. +class _Host extends StatefulWidget { + const _Host(); + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with LogMealKuchniaMixin<_Host> { + final TextEditingController desc = TextEditingController(); + final MacroControllers macros = MacroControllers(); + + @override + TextEditingController get descController => desc; + + @override + MacroControllers get macroControllers => macros; + + /// Stands in for the screen's submit: the entry write is irrelevant here, + /// the queue advance is what is under test. + void submit() { + final logged = desc.text; + desc.clear(); + macros.clear(); + advanceQueueAfterLog(logged); + } + + @override + Widget build(BuildContext context) => + Text(queueStatusLine ?? '', textDirection: TextDirection.ltr); +} + +KuchniaDish _dish(String name, int priority) => KuchniaDish( + name: name, + kcal: 400, + proteinG: 25, + carbsG: 45.5, + fatG: 12, + grams: 300, + priority: priority, + slotLabel: 'Obiad', +); + +void main() { + late _HostState host; + + Future pumpHost(WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _Host())); + host = tester.state(find.byType(_Host)); + } + + setUp(() async { + await KuchniaQueueService.initForTesting(_MemoryStore()); + }); + + tearDown(KuchniaQueueService.resetForTesting); + + testWidgets('after a submit the form already holds the next dish', ( + tester, + ) async { + await pumpHost(tester); + KuchniaQueueService.instance.offer([ + _dish('Pancakes', 1), + _dish('Kaszotto', 2), + ]); + host.prefillNextDish(); + await tester.pump(); + expect(host.desc.text, 'Pancakes'); + + host.submit(); + await tester.pump(); + + expect( + host.desc.text, + 'Kaszotto', + reason: 'the form must already hold dish 2. Empty here means the queue ' + 'is right and the form is not -- the dead-letter regression where ' + 'every dish after the first waits behind another tap.', + ); + expect(KuchniaQueueService.remaining, 1); + }); + + testWidgets('the macros come with the dish, not just the name', ( + tester, + ) async { + await pumpHost(tester); + KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]); + host.prefillNextDish(); + await tester.pump(); + + // Whole numbers print without a trailing `.0`, matching Python's `:g`. + expect(host.macros.kcal.text, '400'); + expect(host.macros.protein.text, '25'); + expect(host.macros.carbs.text, '45.5'); + expect(host.macros.fat.text, '12'); + expect(host.macros.grams.text, '300'); + }); + + testWidgets('the leftover dish is named rather than dropped', (tester) async { + // Five delivered meals against four default slots leaves one queued when + // the last slot is logged. It is already banked, so say so. + await pumpHost(tester); + KuchniaQueueService.instance.offer([_dish('A', 1), _dish('B', 2)]); + host.prefillNextDish(); + await tester.pump(); + + host.submit(); + await tester.pump(); + + expect(find.text('1 more delivered dish to log.'), findsOneWidget); + }); + + testWidgets('editing the description does not consume the offer', ( + tester, + ) async { + // What the user logged was not the delivered dish, so marking it eaten + // would lose a real delivered meal silently. + await pumpHost(tester); + KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]); + host.prefillNextDish(); + await tester.pump(); + + host.desc.text = 'Something else'; + host.submit(); + await tester.pump(); + + expect(KuchniaQueueService.remaining, 1); + }); + + testWidgets('an empty queue clears the offer instead of throwing', ( + tester, + ) async { + await pumpHost(tester); + host.prefillNextDish(); + await tester.pump(); + expect(host.offeredDish, isNull); + expect(host.queueStatusLine, isNull); + }); + + testWidgets('submitting with nothing offered is a no-op', (tester) async { + await pumpHost(tester); + host.submit(); + await tester.pump(); + expect(KuchniaQueueService.remaining, 0); + }); +} diff --git a/app/test/services/kuchnia_queue_test.dart b/app/test/services/kuchnia_queue_test.dart new file mode 100644 index 0000000..ec6f72e --- /dev/null +++ b/app/test/services/kuchnia_queue_test.dart @@ -0,0 +1,151 @@ +/// 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 documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future 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); + }); + }); +}