feat: offer delivered dishes to the log form, one tap each

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 09:07:17 +02:00
parent 5c9c1ff160
commit af35eb20c8
8 changed files with 688 additions and 25 deletions

View File

@ -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

View File

@ -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<T extends StatefulWidget> on State<T> {
/// 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<void> loadDeliveryOnce({Set<String> 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<void> 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.';
}
}

View File

@ -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<LogMealScreen>
with
WidgetsBindingObserver,
LogMealSyncMixin<LogMealScreen>,
LogMealNavMixin<LogMealScreen> {
LogMealNavMixin<LogMealScreen>,
LogMealKuchniaMixin<LogMealScreen> {
@override
http.Client? get syncHttpClient => widget.httpClient;
@ -78,6 +79,9 @@ class _LogMealScreenState extends State<LogMealScreen>
// 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<LogMealScreen>
});
}
@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<LogMealScreen>
});
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<LogMealScreen>
// 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<LogMealScreen>
});
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<void> _dismissStaleReminder() => checkAndNotify(pullWhenDue: false);
@override
Widget build(BuildContext context) {

View File

@ -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<KuchniaDish> _pending = [];
String _lastImportDay = '';
/// The dishes not yet logged, in the caterer's own meal order.
static List<KuchniaDish> 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<KuchniaQueueService> 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<void> _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<void> 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<KuchniaDish> dishes, {Set<String> 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<KuchniaRefresh> 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;
}
}

View File

@ -84,3 +84,28 @@ List<KuchniaDish> dishesInSlotOrder(
List<KuchniaDish> dishes,
List<int> 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),
),
);
}

View File

@ -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

View File

@ -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<String, String> documents = {};
@override
Future<String?> read(String name) async => documents[name];
@override
Future<void> 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<void> 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);
});
}

View File

@ -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<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);
});
});
}