From 5cd89a4e99de79b46a1d3e8748f1da65dfcc3973 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 13:49:58 +0200 Subject: [PATCH] Update app/diet_guard (17 files) Co-Authored-By: Claude Opus 5.5 (1M context) --- app/lib/screens/log_meal_fill_mixin.dart | 79 +++++ app/lib/screens/log_meal_screen.dart | 31 +- app/lib/screens/log_meal_sync_mixin.dart | 27 ++ app/lib/services/kuchnia_fill.dart | 106 +++++++ app/lib/services/kuchnia_fill_flow.dart | 148 ++++++++++ app/lib/widgets/fill_all_row.dart | 77 +++++ app/pubspec.yaml | 2 +- app/test/fill_all_row_test.dart | 91 ++++++ app/test/kuchnia_fill_cancel_test.dart | 40 +++ app/test/kuchnia_fill_flow_test.dart | 250 ++++++++++++++++ app/test/kuchnia_fill_parity_test.dart | 126 ++++++++ app/test/kuchnia_fill_support.dart | 57 ++++ app/test/kuchnia_fill_test.dart | 147 ++++++++++ .../screens/log_meal_fill_mixin_test.dart | 189 ++++++++++++ diet_guard/tests/test_kuchnia_parity.py | 60 +++- docs/DOCS-kuchnia-wikinga-phone.md | 24 ++ scripts/build_kuchnia_fixture.py | 57 +++- tests/fixtures/kuchnia_day.json | 277 ++++++++++++++++++ 18 files changed, 1770 insertions(+), 18 deletions(-) create mode 100644 app/lib/screens/log_meal_fill_mixin.dart create mode 100644 app/lib/services/kuchnia_fill.dart create mode 100644 app/lib/services/kuchnia_fill_flow.dart create mode 100644 app/lib/widgets/fill_all_row.dart create mode 100644 app/test/fill_all_row_test.dart create mode 100644 app/test/kuchnia_fill_cancel_test.dart create mode 100644 app/test/kuchnia_fill_flow_test.dart create mode 100644 app/test/kuchnia_fill_parity_test.dart create mode 100644 app/test/kuchnia_fill_support.dart create mode 100644 app/test/kuchnia_fill_test.dart create mode 100644 app/test/screens/log_meal_fill_mixin_test.dart diff --git a/app/lib/screens/log_meal_fill_mixin.dart b/app/lib/screens/log_meal_fill_mixin.dart new file mode 100644 index 0000000..8dbae44 --- /dev/null +++ b/app/lib/screens/log_meal_fill_mixin.dart @@ -0,0 +1,79 @@ +/// Wires "Fill all with catering" into the log form. +/// +/// Its own file because `log_meal_screen.dart` and +/// `log_meal_kuchnia_mixin.dart` are both at the repo's 250-line ceiling. The +/// rule lives in `kuchnia_fill.dart` and the two-tap state in +/// `kuchnia_fill_flow.dart`; this only connects them to the screen. +library; + +import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart'; +import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart'; +import 'package:diet_guard_app/services/kuchnia_fill_flow.dart'; +import 'package:diet_guard_app/services/kuchnia_queue.dart'; +import 'package:diet_guard_app/services/kuchnia_spread.dart'; +import 'package:flutter/material.dart'; + +/// Drives the screen's [FillAllFlow] and tidies the screen after a fill. +mixin LogMealFillMixin + on LogMealSyncMixin, LogMealKuchniaMixin { + /// The flow behind the button. Per-screen, so leaving the screen drops an + /// armed proposal along with it. + FillAllFlow fillFlow = FillAllFlow(); + + /// Shows [message] in the form's status line. Supplied by the screen. + void showFillStatus(String message); + + /// Runs one tap: the first proposes, a second on the same day confirms. + Future onFillAll() async { + final pending = fillFlow.tap(); + setState(() {}); // show the busy label while the fetch/writes run + final FillOutcome? outcome; + try { + outcome = await pending; + } on Exception catch (error) { + if (!mounted) return; + setState(() {}); + // A confirm can fail partway through its writes. Whatever did land is + // a real meal, so it is published like any other rather than waiting + // for the next tick, and the user is told the batch did not finish. + await publishAfterLog(); + await refreshSlots(); + if (mounted) showFillStatus('Fill all failed: $error'); + return; + } + if (!mounted) return; + setState(() {}); + if (outcome == null) return; + if (outcome.written.isNotEmpty) await _afterFill(outcome.written); + if (!mounted) return; + showFillStatus(outcome.message); + } + + /// Disarms a proposal without writing anything. + void onFillCancel() { + fillFlow.cancel(); + showFillStatus('Fill all cancelled — nothing logged.'); + } + + /// Publishes the batch once and drops the filled dishes from the delivery + /// queue, so "Today's delivery (N more to go)" and the prefilled form do + /// not keep offering meals that were just logged. + Future _afterFill(List written) async { + if (KuchniaQueueService.isInitialized) { + for (final item in written) { + KuchniaQueueService.instance.markLogged(item.dish); + } + } + final offered = offeredDish; + if (offered != null && + written.any((item) => item.dish.bankKey == offered.bankKey)) { + // The prefilled dish was just logged by the fill; leaving it in the + // form would invite a second, duplicate submit. + descController.clear(); + macroControllers.clear(); + } + prefillNextDish(); + await publishAfterLog(); + await refreshSlots(); + } +} diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 67553b7..cf656c4 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -8,17 +8,18 @@ import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_suggestion.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_fill_mixin.dart'; import 'package:diet_guard_app/screens/log_meal_future_mixin.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'; -import 'package:diet_guard_app/services/due_slot_check.dart'; import 'package:diet_guard_app/services/foodbank_service.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/ui/theme.dart'; import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart'; +import 'package:diet_guard_app/widgets/fill_all_row.dart'; import 'package:diet_guard_app/widgets/log_meal_actions_row.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:diet_guard_app/widgets/sync_health_banner.dart'; @@ -46,6 +47,7 @@ class _LogMealScreenState extends State LogMealSyncMixin, LogMealNavMixin, LogMealKuchniaMixin, + LogMealFillMixin, LogMealFutureMixin { @override http.Client? get syncHttpClient => widget.httpClient; @@ -113,6 +115,11 @@ class _LogMealScreenState extends State MacroControllers get macroControllers => _macros; @override void onDishPrefilled() => setState(() => _source = 'catering'); + @override + void showFillStatus(String message) => setState(() { + _status = message; + _progress = null; + }); void _onSuggestionSelected(FoodSuggestion suggestion) { fillControllersFromSuggestion(suggestion, _descController, _macros); @@ -146,20 +153,7 @@ class _LogMealScreenState extends State when: target.when, ); resetFutureLog(); - final log = await LogStorageService.instance.readLog(); - await FoodBankService.instance.rebuildAndPersist(log); - // Push the new meal now instead of waiting for the next lifecycle event, - // so the PC gate can see it in seconds. Fire-and-forget and best-effort: - // autoSync is single-flight and swallows offline/transient failures. - unawaited(autoSync()); - // Offline backstop: if the push above fails (no connectivity), a - // connectivity-gated WorkManager task uploads the meal on reconnect. - unawaited(enqueueSyncBackstopTask()); - // 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); + final log = await publishAfterLog(); if (!mounted) return; _descController.clear(); _macros.clear(); @@ -227,6 +221,13 @@ class _LogMealScreenState extends State deliveryBusy: deliveryBusy, dishesQueued: dishesStillQueued, ), + const SizedBox(height: AppSpacing.sm), + FillAllRow( + onFill: onFillAll, + onCancel: onFillCancel, + busy: fillFlow.busy || deliveryBusy, + armedCount: fillFlow.armedCount, + ), // Mutually exclusive: _status carries a validation complaint, // _progress the post-log summary. A successful log clears one // and sets the other, so the two never stack. diff --git a/app/lib/screens/log_meal_sync_mixin.dart b/app/lib/screens/log_meal_sync_mixin.dart index 16e81bb..fe4199e 100644 --- a/app/lib/screens/log_meal_sync_mixin.dart +++ b/app/lib/screens/log_meal_sync_mixin.dart @@ -3,7 +3,9 @@ import 'dart:developer'; import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/services/background_tasks.dart'; +import 'package:diet_guard_app/services/due_slot_check.dart'; import 'package:diet_guard_app/services/firebase_client.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/health_steps.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -140,6 +142,31 @@ mixin LogMealSyncMixin on State // coverage:ignore-end + /// Everything that must follow a write to the food log, shared by the + /// manual submit and "Fill all" so a batch fill publishes exactly like a + /// typed meal. Call it once per batch, not per entry: [autoSync] is + /// single-flight, so a second call while the first is in flight is dropped + /// and the later entries would wait for the next tick. + /// + /// Returns the log as read after the write. + Future publishAfterLog() async { + final log = await LogStorageService.instance.readLog(); + await FoodBankService.instance.rebuildAndPersist(log); + // Push the new meal now instead of waiting for the next lifecycle event, + // so the PC gate can see it in seconds. Fire-and-forget and best-effort: + // autoSync is single-flight and swallows offline/transient failures. + unawaited(autoSync()); + // Offline backstop: if the push above fails (no connectivity), a + // connectivity-gated WorkManager task uploads the meal on reconnect. + unawaited(enqueueSyncBackstopTask()); + // 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); + return log; + } + /// Re-reads which meal slots are already logged today. Future refreshSlots() async { final logged = await LogStorageService.instance.loggedSlotsToday(); diff --git a/app/lib/services/kuchnia_fill.dart b/app/lib/services/kuchnia_fill.dart new file mode 100644 index 0000000..d1da9b3 --- /dev/null +++ b/app/lib/services/kuchnia_fill.dart @@ -0,0 +1,106 @@ +/// "Fill all with catering": which delivered dishes go into today's log. +/// +/// The Dart mirror of `diet_guard/_kuchnia_log.py`'s `fill_plan` and +/// `log_dishes`. KEEP IN SYNC, gated by the shared parity fixture. +/// +/// **Nothing here runs without an explicit confirm tap.** A delivered meal is +/// not an eaten meal: logging unattended would let the delivery note satisfy +/// the very checkpoints the gate exists to enforce. [logDishes] is only ever +/// reached from the second tap of `FillAllFlow`. +library; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/kuchnia_spread.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; + +/// Marks entries this feature created, the same tag the PC's Fill all writes +/// (`_kuchnia_log.SOURCE`), so both devices' batch fills read alike. +const kuchniaSource = 'kuchnia wikinga'; + +/// Keeps only the dishes whose slot has nothing logged in it today. +/// +/// Fills *empty* slots: a slot the user already logged something into keeps +/// that meal rather than gaining a second one. [occupied] is read once, before +/// the batch, so two dishes the spread put in the same empty slot (5 dishes on +/// 4 slots) both land. +/// +/// Pure, unlike Python's `fill_plan` which reads `today_entries()` itself: +/// the caller passes occupancy so the rule is testable without storage. +List fillPlan(List slotted, Set occupied) => [ + for (final item in slotted) + if (!occupied.contains(item.slot)) item, +]; + +/// Returns [dish] as the whole-portion [Nutrition] it gets logged as. +/// +/// Goes through [nutritionForPortion] with no "per" basis -- exactly what the +/// "Today's delivery" prefill does when its form is submitted -- so a filled +/// entry is indistinguishable from a prefilled one (including its 0.1 g +/// rounding), apart from [kuchniaSource]. +Nutrition dishNutrition(KuchniaDish dish) => nutritionForPortion( + kcal: dish.kcal, + proteinG: dish.proteinG, + carbsG: dish.carbsG, + fatG: dish.fatG, + perGrams: 0, + ateGrams: dish.grams, + source: kuchniaSource, +); + +String _dedupKey(String desc, int? slot) => + '${desc.trim().toLowerCase()}\u0000$slot'; + +/// Logs each chosen dish, skipping any already logged today in that slot. +/// +/// Mirrors `log_dishes`, including its intra-batch skip: two identical dishes +/// spread into the same slot land once, because the write is reflected in the +/// seen set before the next dish is checked. +/// +/// Writes through [LogStorageService.logMeal], the same call a manual log +/// makes; the caller then publishes once for the whole batch. +/// +/// Returns the dishes actually written, in order. +Future> logDishes( + List chosen, { + LogStorageService? storage, +}) async { + final store = storage ?? LogStorageService.instance; + final seen = { + for (final entry in await store.todayEntries()) + _dedupKey(entry.desc, entry.slot), + }; + final written = []; + for (final item in chosen) { + final key = _dedupKey(item.dish.name, item.slot); + if (!seen.add(key)) continue; + await store.logMeal( + item.dish.name, + dishNutrition(item.dish), + slot: item.slot, + ); + written.add(item); + } + return written; +} + +/// Formats like Python's `f"{value:g}"` for the values a menu carries: +/// "435" rather than "435.0", one decimal otherwise. +String _g(double value) { + final fixed = value.toStringAsFixed(1); + return fixed.endsWith('.0') ? fixed.substring(0, fixed.length - 2) : fixed; +} + +/// The proposal line shown after the first tap, as on the PC gate. +String planSummary(List plan) { + final total = plan.fold(0, (sum, item) => sum + item.dish.kcal); + final listing = plan + .map( + (item) => + '${item.slot.toString().padLeft(2, '0')}:00 ' + '${item.dish.name}', + ) + .join(', '); + return 'Will log ${plan.length} (${_g(total)} kcal): $listing' + ' — tap Confirm.'; +} diff --git a/app/lib/services/kuchnia_fill_flow.dart b/app/lib/services/kuchnia_fill_flow.dart new file mode 100644 index 0000000..40e3a5e --- /dev/null +++ b/app/lib/services/kuchnia_fill_flow.dart @@ -0,0 +1,148 @@ +/// The two-tap state machine behind "Fill all with catering". +/// +/// Mirrors `diet_guard/_gatelock_fillall.py`'s `FillAllFlow`. Two taps, never +/// one: the first fetches today's delivery and proposes exactly what would be +/// written; only the second writes. No modal dialog -- the proposal is a +/// status line and the button relabels to "✓ Confirm (N)". +/// +/// Widget-free so the flow's guarantees (first tap writes nothing, confirm +/// re-checks occupancy, a plan from before midnight refetches) are testable +/// with an injected clock and fetch. +library; + +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/kuchnia_fill.dart'; +import 'package:diet_guard_app/services/kuchnia_import.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'; + +/// Fetches one day's delivery. +typedef DeliveryFetch = Future Function(DateTime day); + +/// What one tap produced: a line for the user, and what (if anything) landed. +typedef FillOutcome = ({String message, List written}); + +/// The empty "nothing written" half of a [FillOutcome]. +const _none = []; + +/// The status line when every slot the delivery covers already holds a meal. +const nothingToFillMessage = 'Every slot already has a meal — nothing to fill.'; + +/// The explicit tap's fetch: unguarded, like "Today's delivery", and it +/// records the fetch so the automatic path is spared a second walk. +Future _fetchAndRecord(DateTime day) async { + final result = await refreshDelivery(day); + if (result.ok && + result.dishes.isNotEmpty && + KuchniaQueueService.isInitialized) { + await KuchniaQueueService.instance.recordFetched(day); + } + return result; +} + +/// Drives one screen's "Fill all" button through fetch, confirm and log. +class FillAllFlow { + /// Creates a flow; every dependency is injectable for tests. + FillAllFlow({ + DeliveryFetch? fetch, + DateTime Function()? now, + List Function()? slots, + Future> Function()? occupiedSlots, + LogStorageService? storage, + }) : _fetch = fetch ?? _fetchAndRecord, + _now = now ?? DateTime.now, + _slots = slots ?? (() => daySlots(MealScheduleService.current)), + _occupied = + occupiedSlots ?? + (() => (storage ?? LogStorageService.instance).loggedSlotsToday()), + _storage = storage; + + final DeliveryFetch _fetch; + final DateTime Function() _now; + final List Function() _slots; + final Future> Function() _occupied; + final LogStorageService? _storage; + + List _plan = const []; + DateTime? _planDay; + + /// True while a fetch or a confirm's writes are in flight. A tap meanwhile + /// is ignored, so a double tap can neither log in twice nor write twice. + bool busy = false; + + /// How many dishes the armed proposal would log; 0 when nothing is armed. + int get armedCount => _plan.length; + + DateTime _today() { + final now = _now(); + return DateTime(now.year, now.month, now.day); + } + + /// First tap fetches and proposes; a second tap on the same day confirms. + /// + /// Returns null when the tap was ignored because one is already in flight, + /// or when [cancel] ran while its fetch was. + Future tap() async { + if (busy) return null; + final today = _today(); + if (_plan.isNotEmpty && _planDay == today) return await _confirm(); + // No plan, or one proposed before midnight: fetch (again). The day is + // pinned here, not when the result lands, so a fetch still in flight at + // midnight can never confirm yesterday's menu into today. + cancel(); + _planDay = today; + busy = true; + try { + return await _propose(today); + } finally { + busy = false; + } + } + + Future _propose(DateTime today) async { + final result = await _fetch(today); + // Cancelled while the fetch was in flight: arming now would show + // "Confirm (N)" for a plan the user already dismissed. + if (_planDay != today) return null; + if (!result.ok) return (message: result.reason!, written: _none); + if (result.dishes.isEmpty) { + return (message: 'No catering delivery today.', written: _none); + } + final plan = fillPlan( + assignSlots(result.dishes, _slots()), + await _occupied(), + ); + if (plan.isEmpty) return (message: nothingToFillMessage, written: _none); + _plan = plan; + return (message: planSummary(plan), written: _none); + } + + Future _confirm() async { + busy = true; + try { + // Re-checked at write time: a meal logged in between the two taps -- + // here or synced from the PC -- has taken its slot and keeps it. + final plan = fillPlan(_plan, await _occupied()); + cancel(); + if (plan.isEmpty) { + return (message: nothingToFillMessage, written: _none); + } + final written = await logDishes(plan, storage: _storage); + final noun = written.length == 1 ? 'dish' : 'dishes'; + return ( + message: 'Logged ${written.length} catering $noun.', + written: written, + ); + } finally { + busy = false; + } + } + + /// Forgets any proposal, restoring the button's resting label. + void cancel() { + _plan = const []; + _planDay = null; + } +} diff --git a/app/lib/widgets/fill_all_row.dart b/app/lib/widgets/fill_all_row.dart new file mode 100644 index 0000000..64f158b --- /dev/null +++ b/app/lib/widgets/fill_all_row.dart @@ -0,0 +1,77 @@ +/// The log form's "Fill all with catering" row: one button, plus Cancel while +/// a proposal is armed. +/// +/// Its own row rather than a third control in [LogMealActionsRow]: that row is +/// already full on a narrow phone, and "✓ Confirm (N)" must stay legible. +library; + +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; + +/// Resting label, mirroring the PC gate's "🍱 Fill all". +const fillAllLabel = '🍱 Fill all with catering'; + +/// Fill-all button; relabels to "✓ Confirm (N)" once a proposal is armed. +class FillAllRow extends StatelessWidget { + /// Creates the row. + const FillAllRow({ + required this.onFill, + required this.onCancel, + this.busy = false, + this.armedCount = 0, + this.canFetchDelivery, + super.key, + }); + + /// First tap proposes, second tap confirms. + final Future Function() onFill; + + /// Disarms a proposal without writing anything. + final VoidCallback onCancel; + + /// Disables both controls while a fetch or the confirm's writes run. + final bool busy; + + /// How many dishes the armed proposal would log; 0 when none is armed. + final int armedCount; + + /// Whether the platform can fetch at all. Defaults to + /// [kuchniaFetchSupported] -- false on web, where the caterer's panel blocks + /// browser requests -- so the row is hidden there, like the delivery button. + final bool? canFetchDelivery; + + @override + Widget build(BuildContext context) { + if (!(canFetchDelivery ?? kuchniaFetchSupported)) { + return const SizedBox.shrink(); + } + final armed = armedCount > 0; + return Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: busy ? null : onFill, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text(_label(armed), maxLines: 1), + ), + ), + ), + if (armed) ...[ + const SizedBox(width: AppSpacing.sm), + TextButton( + onPressed: busy ? null : onCancel, + child: const Text('Cancel'), + ), + ], + ], + ); + } + + String _label(bool armed) { + if (busy) return 'Working…'; + if (armed) return '✓ Confirm ($armedCount)'; + return fillAllLabel; + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b34ab13..b2ddcf8 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.103+1 +version: 1.0.104+1 environment: sdk: ^3.12.2 diff --git a/app/test/fill_all_row_test.dart b/app/test/fill_all_row_test.dart new file mode 100644 index 0000000..4bee33c --- /dev/null +++ b/app/test/fill_all_row_test.dart @@ -0,0 +1,91 @@ +/// The "Fill all with catering" row: label per state, Cancel only when armed. +library; + +import 'package:diet_guard_app/widgets/fill_all_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _pump( + WidgetTester tester, { + bool canFetch = true, + bool busy = false, + int armed = 0, + Future Function()? onFill, + VoidCallback? onCancel, +}) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FillAllRow( + onFill: onFill ?? () async {}, + onCancel: onCancel ?? () {}, + busy: busy, + armedCount: armed, + canFetchDelivery: canFetch, + ), + ), + ), +); + +void main() { + testWidgets('is hidden where delivery cannot be fetched (web)', (t) async { + await _pump(t, canFetch: false, armed: 3); + + expect(find.byType(OutlinedButton), findsNothing); + expect(find.text(fillAllLabel), findsNothing); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('resting: the fill label and no Cancel', (t) async { + await _pump(t); + + expect(find.text('🍱 Fill all with catering'), findsOneWidget); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('armed: Confirm (N) and a Cancel button', (t) async { + await _pump(t, armed: 3); + + expect(find.text('✓ Confirm (3)'), findsOneWidget); + expect(find.text(fillAllLabel), findsNothing); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('busy: Working… and both controls disabled', (t) async { + var fills = 0; + var cancels = 0; + await _pump( + t, + busy: true, + armed: 3, + onFill: () async => fills++, + onCancel: () => cancels++, + ); + + expect(find.text('Working…'), findsOneWidget); + expect(find.text('✓ Confirm (3)'), findsNothing); + expect( + t.widget(find.byType(OutlinedButton)).onPressed, + isNull, + ); + expect(t.widget(find.byType(TextButton)).onPressed, isNull); + await t.tap(find.byType(OutlinedButton), warnIfMissed: false); + await t.tap(find.byType(TextButton), warnIfMissed: false); + expect(fills + cancels, 0); + }); + + testWidgets('taps reach the callbacks when idle', (t) async { + var fills = 0; + var cancels = 0; + await _pump( + t, + armed: 2, + onFill: () async => fills++, + onCancel: () => cancels++, + ); + + await t.tap(find.text('✓ Confirm (2)')); + await t.tap(find.text('Cancel')); + + expect([fills, cancels], [1, 1]); + }); +} diff --git a/app/test/kuchnia_fill_cancel_test.dart b/app/test/kuchnia_fill_cancel_test.dart new file mode 100644 index 0000000..b0b8d25 --- /dev/null +++ b/app/test/kuchnia_fill_cancel_test.dart @@ -0,0 +1,40 @@ +/// Cancelling "Fill all" while its first-tap fetch is still in flight. +/// +/// Its own file because `kuchnia_fill_flow_test.dart` is at the 250-line cap. +/// The UI disables Cancel while busy, so this guards the flow itself: a +/// dismissed proposal must not arm when the late fetch lands. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/services/kuchnia_fill_flow.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'kuchnia_fill_support.dart'; + +void main() { + tearDown(LogStorageService.resetForTesting); + + test('cancel during the fetch leaves nothing armed', () async { + final gate = Completer(); + final flow = FillAllFlow( + fetch: (_) async { + await gate.future; + return KuchniaRefresh(dishes: dishes(4)); + }, + now: () => DateTime(2026, 8, 23, 10), + slots: () => defaultSlots, + occupiedSlots: () async => {}, + storage: freshStorage(), + ); + + final pending = flow.tap(); + flow.cancel(); + gate.complete(); + + expect(await pending, isNull); + expect(flow.armedCount, 0); + }); +} diff --git a/app/test/kuchnia_fill_flow_test.dart b/app/test/kuchnia_fill_flow_test.dart new file mode 100644 index 0000000..91c9e5a --- /dev/null +++ b/app/test/kuchnia_fill_flow_test.dart @@ -0,0 +1,250 @@ +/// The two-tap state machine behind "Fill all with catering". +/// +/// The guarantee under test is that *no write happens on the first tap* and +/// that the confirm re-reads the world: occupancy at write time, and the +/// calendar day. Each test asserts the log's contents, not a return value. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/services/kuchnia_fill.dart'; +import 'package:diet_guard_app/services/kuchnia_fill_flow.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'kuchnia_fill_support.dart'; + +/// Counts and records fetches; the result is swappable per test. +class _Fetcher { + _Fetcher(this.result); + + KuchniaRefresh result; + final List days = []; + Completer? gate; + + Future call(DateTime day) async { + days.add(day); + if (gate != null) await gate!.future; + return result; + } +} + +void main() { + late LogStorageService storage; + late _Fetcher fetcher; + late DateTime clock; + late Set occupied; + late int occupancyReads; + Completer? occupancyGate; + late FillAllFlow flow; + + setUp(() { + storage = freshStorage(); + fetcher = _Fetcher(KuchniaRefresh(dishes: dishes(4))); + clock = DateTime(2026, 8, 23, 10); + occupied = {}; + occupancyReads = 0; + occupancyGate = null; + flow = FillAllFlow( + fetch: fetcher.call, + now: () => clock, + slots: () => defaultSlots, + occupiedSlots: () async { + occupancyReads++; + if (occupancyGate != null) await occupancyGate!.future; + return {...occupied}; + }, + storage: storage, + ); + }); + + tearDown(LogStorageService.resetForTesting); + + test('the first tap proposes and writes nothing', () async { + final outcome = await flow.tap(); + + expect(outcome!.written, isEmpty); + expect(outcome.message, startsWith('Will log 4')); + expect(flow.armedCount, 4); + expect(await storage.todayEntries(), isEmpty); + expect(fetcher.days, [DateTime(2026, 8, 23)]); + }); + + test('the second tap logs the plan with the catering source', () async { + await flow.tap(); + final outcome = await flow.tap(); + + expect(outcome!.message, 'Logged 4 catering dishes.'); + expect(await loggedToday(storage), ['D1@8', 'D2@12', 'D3@16', 'D4@20']); + expect( + (await storage.todayEntries()).map((e) => e.source), + everyElement(kuchniaSource), + ); + expect(flow.armedCount, 0); + expect(fetcher.days, hasLength(1), reason: 'confirm must not refetch'); + }); + + test( + 'confirm re-checks occupancy: a meal logged between taps stays', + () async { + await flow.tap(); + expect(flow.armedCount, 4); + // Logged on the PC (or here) after the proposal was shown. + await storage.logMeal( + 'my own lunch', + dishNutrition(dish('x', 1)), + slot: 12, + ); + occupied = {12}; + + final outcome = await flow.tap(); + + expect(outcome!.message, 'Logged 3 catering dishes.'); + expect(await loggedToday(storage), [ + 'my own lunch@12', + 'D1@8', + 'D3@16', + 'D4@20', + ]); + expect(occupancyReads, 2, reason: 'once to propose, once to confirm'); + }, + ); + + test('a confirm into a now-full day writes nothing', () async { + await flow.tap(); + occupied = {8, 12, 16, 20}; + + final outcome = await flow.tap(); + + expect(outcome!.message, nothingToFillMessage); + expect(outcome.written, isEmpty); + expect(await storage.todayEntries(), isEmpty); + expect(flow.armedCount, 0); + }); + + test( + '5 dishes on 4 slots: both dishes of an empty slot are logged', + () async { + fetcher.result = KuchniaRefresh(dishes: dishes(5)); + await flow.tap(); + await flow.tap(); + + expect(await loggedToday(storage), [ + 'D1@8', + 'D2@8', + 'D3@12', + 'D4@16', + 'D5@20', + ]); + }, + ); + + group('midnight', () { + test('a plan from yesterday is refetched, not confirmed', () async { + clock = DateTime(2026, 8, 23, 23, 59); + await flow.tap(); + expect(flow.armedCount, 4); + + clock = DateTime(2026, 8, 24, 0, 1); + final outcome = await flow.tap(); + + expect(fetcher.days, [DateTime(2026, 8, 23), DateTime(2026, 8, 24)]); + expect(outcome!.written, isEmpty); + expect(outcome.message, startsWith('Will log 4')); + expect(await storage.todayEntries(), isEmpty); + expect(flow.armedCount, 4, reason: 're-armed for the new day'); + }); + + test('the day is pinned at tap time, not when the fetch lands', () async { + clock = DateTime(2026, 8, 23, 23, 59); + fetcher.gate = Completer(); + final pending = flow.tap(); + clock = DateTime(2026, 8, 24, 0, 1); + fetcher.gate!.complete(); + await pending; + + // That menu was fetched for the 23rd: a tap on the 24th must refetch + // rather than confirm it. + fetcher.gate = null; + final outcome = await flow.tap(); + + expect(fetcher.days, [DateTime(2026, 8, 23), DateTime(2026, 8, 24)]); + expect(outcome!.written, isEmpty); + expect(await storage.todayEntries(), isEmpty); + }); + }); + + group('nothing to arm', () { + test('a fetch error surfaces its reason', () async { + fetcher.result = const KuchniaRefresh(reason: 'Login failed (401).'); + final outcome = await flow.tap(); + + expect(outcome!.message, 'Login failed (401).'); + expect(flow.armedCount, 0); + }); + + test('no delivery says so', () async { + fetcher.result = const KuchniaRefresh(); + final outcome = await flow.tap(); + + expect(outcome!.message, 'No catering delivery today.'); + expect(flow.armedCount, 0); + }); + + test('all slots full arms nothing', () async { + occupied = {8, 12, 16, 20}; + final outcome = await flow.tap(); + + expect(outcome!.message, nothingToFillMessage); + expect(flow.armedCount, 0); + // The next tap fetches again instead of "confirming" an empty plan. + await flow.tap(); + expect(fetcher.days, hasLength(2)); + expect(await storage.todayEntries(), isEmpty); + }); + }); + + group('busy', () { + test('a tap while the fetch is pending is ignored', () async { + fetcher.gate = Completer(); + final first = flow.tap(); + expect(flow.busy, isTrue); + + expect(await flow.tap(), isNull); + expect(fetcher.days, hasLength(1)); + + fetcher.gate!.complete(); + final outcome = await first; + expect(flow.busy, isFalse); + expect(outcome!.message, startsWith('Will log 4')); + expect(await storage.todayEntries(), isEmpty); + }); + + test('a second tap during the confirm cannot log twice', () async { + await flow.tap(); + occupancyGate = Completer(); + final confirm = flow.tap(); + expect(flow.busy, isTrue); + + expect(await flow.tap(), isNull); + + occupancyGate!.complete(); + await confirm; + expect(flow.busy, isFalse); + expect(await loggedToday(storage), hasLength(4)); + }); + }); + + test('cancel disarms; the next tap fetches again', () async { + await flow.tap(); + flow.cancel(); + + expect(flow.armedCount, 0); + final outcome = await flow.tap(); + + expect(fetcher.days, hasLength(2)); + expect(outcome!.written, isEmpty); + expect(await storage.todayEntries(), isEmpty); + }); +} diff --git a/app/test/kuchnia_fill_parity_test.dart b/app/test/kuchnia_fill_parity_test.dart new file mode 100644 index 0000000..3493ccc --- /dev/null +++ b/app/test/kuchnia_fill_parity_test.dart @@ -0,0 +1,126 @@ +/// The Dart half of the "Fill all with catering" parity gate. +/// +/// `diet_guard/tests/test_kuchnia_parity.py` runs Python's `fill_plan` and +/// `log_dishes` over the *same* `expected.fill` cases of +/// `tests/fixtures/kuchnia_day.json`, whose expected lists are generated from +/// the Python implementation by `scripts/build_kuchnia_fixture.py`. A +/// divergence means the two devices fill different slots from one delivery. +/// +/// Each case's `take` indexes the parsed dishes; the subset is re-spread over +/// `slots` (never a slice of the full spread). Split from +/// `kuchnia_parity_test.dart` to keep both under the 250-line cap. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/kuchnia_fill.dart'; +import 'package:diet_guard_app/services/kuchnia_parse.dart'; +import 'package:diet_guard_app/services/kuchnia_spread.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'services/body_test_support.dart'; + +/// The repo root, found by walking up to `app/` (as `kuchnia_parity_test`). +Directory get _repoRoot { + var dir = Directory.current; + while (!File('${dir.path}/pubspec.yaml').existsSync()) { + final parent = dir.parent; + if (parent.path == dir.path) { + fail('could not locate app/ from ${Directory.current.path}'); + } + dir = parent; + } + return dir.parent; +} + +const _seedNutrition = Nutrition( + kcal: 100, + proteinG: 1, + carbsG: 1, + fatG: 1, + grams: 100, + source: 'manual', +); + +List> _pairs(Iterable items) => [ + for (final item in items) [item.dish.name, item.slot], +]; + +void main() { + late Map fill; + late List dishes; + + setUpAll(() { + final file = File('${_repoRoot.path}/tests/fixtures/kuchnia_day.json'); + final fixture = jsonDecode(file.readAsStringSync()) as Map; + fill = + (fixture['expected'] as Map)['fill'] + as Map; + dishes = parseMenu(fixture['payload']); + }); + + setUp(() => LogStorageService.resetForTesting(store: MemoryDocStore())); + tearDown(LogStorageService.resetForTesting); + + List spread(Map testCase) => assignSlots([ + for (final i in testCase['take'] as List) dishes[i as int], + ], (fill['slots'] as List).cast()); + + Map cases(String group) => + fill[group] as Map; + + test('fillPlan keeps the same dishes as Python fill_plan', () { + expect(cases('fill_plan'), isNotEmpty); + for (final entry in cases('fill_plan').entries) { + final testCase = entry.value as Map; + final occupied = (testCase['occupied'] as List).cast().toSet(); + expect( + _pairs(fillPlan(spread(testCase), occupied)), + equals(testCase['expected']), + reason: 'fillPlan diverged for ${entry.key}', + ); + } + }); + + test('logDishes writes the same dishes as Python log_dishes', () async { + expect(cases('log_dishes'), isNotEmpty); + for (final entry in cases('log_dishes').entries) { + LogStorageService.resetForTesting(store: MemoryDocStore()); + final storage = LogStorageService.instance; + final testCase = entry.value as Map; + final seeded = testCase['today'] as List; + for (final row in seeded.cast>()) { + await storage.logMeal( + row['desc'] as String, + _seedNutrition, + slot: row['slot'] as int, + ); + } + final returned = await logDishes(spread(testCase), storage: storage); + final logged = [ + for (final e in (await storage.todayEntries()).skip(seeded.length)) + [e.desc, e.slot], + ]; + final reason = 'logDishes diverged for ${entry.key}'; + expect(logged, equals(testCase['expected']), reason: reason); + expect(_pairs(returned), equals(testCase['expected']), reason: reason); + } + }); + + test('the twin case really puts two identical dishes in one slot', () { + // Guards against a regenerated fixture that separates the twins and so + // silently stops testing the intra-batch dedup. + final testCase = cases('log_dishes')['twins_share_16'] as Map; + final input = _pairs(spread(testCase.cast())); + bool isTwin16(List p) => p[0] == 'Twin dish' && p[1] == 16; + expect(input.where(isTwin16), hasLength(2)); + expect( + (testCase['expected'] as List).cast>().where(isTwin16), + hasLength(1), + ); + }); +} diff --git a/app/test/kuchnia_fill_support.dart b/app/test/kuchnia_fill_support.dart new file mode 100644 index 0000000..2eebaa9 --- /dev/null +++ b/app/test/kuchnia_fill_support.dart @@ -0,0 +1,57 @@ +/// Shared fixtures for the "Fill all with catering" tests. +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_spread.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; + +/// In-memory store, so these tests need no plugin channel or temp dir. +class MemoryStore implements DocumentStore { + /// Stored documents by name. + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +/// The default four-slot day. +const defaultSlots = [8, 12, 16, 20]; + +/// A dish with the given [priority]; macros are distinct per priority. +KuchniaDish dish(String name, int priority, {double grams = 300}) => + KuchniaDish( + name: name, + kcal: 100.0 * priority, + proteinG: 10.0 * priority, + carbsG: 20.5, + fatG: 5, + grams: grams, + priority: priority, + slotLabel: 'Obiad', + ); + +/// [count] dishes named `D1..Dn` in priority order. +List dishes(int count) => [ + for (var i = 1; i <= count; i++) dish('D$i', i), +]; + +/// Pairs [dish] with [slot] directly, bypassing the spread. +SlottedDish at(KuchniaDish dish, int slot) => + SlottedDish(dish: dish, slot: slot); + +/// Points the singleton at a fresh in-memory log and returns it. +LogStorageService freshStorage() { + LogStorageService.resetForTesting(store: MemoryStore()); + return LogStorageService.instance; +} + +/// `desc@slot` for every live entry logged today, in write order. +Future> loggedToday(LogStorageService storage) async => [ + for (final e in await storage.todayEntries()) '${e.desc}@${e.slot}', +]; diff --git a/app/test/kuchnia_fill_test.dart b/app/test/kuchnia_fill_test.dart new file mode 100644 index 0000000..1b2dfd8 --- /dev/null +++ b/app/test/kuchnia_fill_test.dart @@ -0,0 +1,147 @@ +/// The fill rule and the batch write behind "Fill all with catering". +/// +/// Mirrors the PC's `fill_plan` / `log_dishes`. The assertions that matter +/// count what *lands*: a rule that drops an occupied slot but double-writes +/// an empty one still shows "the dish was offered". +library; + +import 'package:diet_guard_app/services/kuchnia_fill.dart'; +import 'package:diet_guard_app/services/kuchnia_spread.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'kuchnia_fill_support.dart'; + +List _names(List plan) => [ + for (final item in plan) '${item.dish.name}@${item.slot}', +]; + +void main() { + tearDown(LogStorageService.resetForTesting); + + group('fillPlan', () { + test('an empty day keeps every dish', () { + final slotted = assignSlots(dishes(4), defaultSlots); + expect(_names(fillPlan(slotted, {})), [ + 'D1@8', + 'D2@12', + 'D3@16', + 'D4@20', + ]); + }); + + test('an occupied slot is skipped, the others stay', () { + final slotted = assignSlots(dishes(4), defaultSlots); + expect(_names(fillPlan(slotted, {12})), ['D1@8', 'D3@16', 'D4@20']); + }); + + test('5 dishes on 4 slots: both dishes of one empty slot stay', () { + final slotted = assignSlots(dishes(5), defaultSlots); + // The spread doubles the first slot; occupancy is read once, so the + // first dish must not shadow the second. + expect(_names(slotted).take(2), ['D1@8', 'D2@8']); + expect(_names(fillPlan(slotted, {})), hasLength(5)); + expect(_names(fillPlan(slotted, {16})), [ + 'D1@8', + 'D2@8', + 'D3@12', + 'D5@20', + ]); + }); + + test('an occupied doubled slot drops both of its dishes', () { + final slotted = assignSlots(dishes(5), defaultSlots); + expect(_names(fillPlan(slotted, {8})), ['D3@12', 'D4@16', 'D5@20']); + }); + + test('a full day yields an empty plan', () { + final slotted = assignSlots(dishes(4), defaultSlots); + expect(fillPlan(slotted, {8, 12, 16, 20}), isEmpty); + }); + }); + + group('dishNutrition', () { + test('is the whole portion, tagged as kuchnia wikinga', () { + final nutrition = dishNutrition(dish('D2', 2, grams: 312.4)); + expect(nutrition.source, 'kuchnia wikinga'); + expect(nutrition.source, kuchniaSource); + expect(nutrition.grams, 312.4); + expect(nutrition.kcal, 200); + expect(nutrition.proteinG, 20); + expect(nutrition.carbsG, 20.5); + expect(nutrition.fatG, 5); + }); + }); + + group('logDishes', () { + test( + 'writes through storage with source and whole-portion grams', + () async { + final storage = freshStorage(); + final chosen = [ + at(dish('D1', 1, grams: 250), 8), + at(dish('D3', 3), 16), + ]; + + final written = await logDishes(chosen, storage: storage); + + expect(_names(written), ['D1@8', 'D3@16']); + final entries = await storage.todayEntries(); + expect(entries.map((e) => e.desc), ['D1', 'D3']); + expect(entries.map((e) => e.slot), [8, 16]); + expect(entries.map((e) => e.source), everyElement('kuchnia wikinga')); + expect(entries.map((e) => e.grams), [250, 300]); + expect(entries.map((e) => e.kcal), [100, 300]); + }, + ); + + test('skips a dish already logged in that slot', () async { + final storage = freshStorage(); + await storage.logMeal(' d1 ', dishNutrition(dish('D1', 1)), slot: 8); + + final written = await logDishes([ + at(dish('D1', 1), 8), + at(dish('D2', 2), 12), + ], storage: storage); + + // Case and surrounding whitespace do not make it a different meal. + expect(_names(written), ['D2@12']); + expect(await storage.todayEntries(), hasLength(2)); + }); + + test('an intra-batch duplicate lands once', () async { + final storage = freshStorage(); + final twin = dish('Twin', 1); + + final written = await logDishes([ + at(twin, 8), + at(twin, 8), + ], storage: storage); + + expect(_names(written), ['Twin@8']); + expect(await loggedToday(storage), ['Twin@8']); + }); + + test('the same dish in a different slot is not a duplicate', () async { + final storage = freshStorage(); + await storage.logMeal('D1', dishNutrition(dish('D1', 1)), slot: 8); + + final written = await logDishes([ + at(dish('D1', 1), 12), + ], storage: storage); + + expect(_names(written), ['D1@12']); + expect(await loggedToday(storage), ['D1@8', 'D1@12']); + }); + }); + + group('planSummary', () { + test('lists slot, dish and the kcal total', () { + final plan = [at(dish('D1', 1), 8), at(dish('D2', 2), 12)]; + expect( + planSummary(plan), + 'Will log 2 (300 kcal): 08:00 D1, 12:00 D2 — tap Confirm.', + ); + }); + }); +} diff --git a/app/test/screens/log_meal_fill_mixin_test.dart b/app/test/screens/log_meal_fill_mixin_test.dart new file mode 100644 index 0000000..46a9d9b --- /dev/null +++ b/app/test/screens/log_meal_fill_mixin_test.dart @@ -0,0 +1,189 @@ +/// "Fill all with catering" as the log form drives it. +/// +/// `kuchnia_fill_flow_test.dart` proves the flow writes nothing until the +/// second tap. This covers what the *screen* must do around it: publish once +/// for the whole batch (and never after the first tap), drop the filled +/// dishes from the delivery queue, and not leave a just-logged dish +/// prefilled in the form where a second submit would duplicate it. +/// +/// Driven through a minimal host, like `log_meal_kuchnia_mixin_test.dart`: +/// the real `LogMealScreen` fans out into sync and notifications and does not +/// pump to quiescence in a widget test. +library; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/screens/log_meal_fill_mixin.dart'; +import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart'; +import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart'; +import 'package:diet_guard_app/services/kuchnia_fill.dart'; +import 'package:diet_guard_app/services/kuchnia_fill_flow.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:diet_guard_app/services/kuchnia_queue.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../kuchnia_fill_support.dart'; + +class _Host extends StatefulWidget { + const _Host(this.storage, this.delivery); + + final LogStorageService storage; + final List delivery; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> + with + WidgetsBindingObserver, + LogMealSyncMixin<_Host>, + LogMealKuchniaMixin<_Host>, + LogMealFillMixin<_Host> { + final TextEditingController desc = TextEditingController(); + final MacroControllers macros = MacroControllers(); + int publishes = 0; + String status = ''; + + @override + int? selectedSlot; + + @override + http.Client? get syncHttpClient => null; + + @override + TextEditingController get descController => desc; + + @override + MacroControllers get macroControllers => macros; + + @override + void initState() { + super.initState(); + fillFlow = FillAllFlow( + fetch: (_) async => KuchniaRefresh(dishes: widget.delivery), + slots: () => defaultSlots, + storage: widget.storage, + ); + } + + @override + void showFillStatus(String message) => setState(() => status = message); + + /// Counted instead of run: the real one starts a network sync. + @override + Future publishAfterLog() { + publishes++; + return widget.storage.readLog(); + } + + @override + Widget build(BuildContext context) => + Text(status, textDirection: TextDirection.ltr); +} + +void main() { + late LogStorageService storage; + late _HostState host; + final delivery = dishes(4); + + Future pumpHost(WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: _Host(storage, delivery))); + host = tester.state(find.byType(_Host)); + // Mirrors the screen: the delivery is queued and the first dish prefilled. + KuchniaQueueService.instance.offer(delivery); + host.prefillNextDish(); + } + + setUp(() async { + storage = freshStorage(); + await KuchniaQueueService.initForTesting(MemoryStore()); + }); + + tearDown(() { + KuchniaQueueService.resetForTesting(); + LogStorageService.resetForTesting(); + }); + + testWidgets('the first tap only proposes: no write, no publish', (t) async { + await pumpHost(t); + + await t.runAsync(host.onFillAll); + + expect(host.status, startsWith('Will log 4')); + expect(await t.runAsync(storage.todayEntries), isEmpty); + expect(host.publishes, 0); + expect(KuchniaQueueService.remaining, 4); + expect(host.desc.text, 'D1'); + }); + + testWidgets('confirm logs the batch, publishes once, empties the queue', ( + t, + ) async { + await pumpHost(t); + + await t.runAsync(() async { + await host.onFillAll(); + await host.onFillAll(); + }); + + expect(host.status, 'Logged 4 catering dishes.'); + expect(await t.runAsync(() => loggedToday(storage)), hasLength(4)); + expect(host.publishes, 1, reason: 'one publish per batch, not per dish'); + expect(KuchniaQueueService.remaining, 0); + expect(host.loggedSlots, {8, 12, 16, 20}); + expect(host.desc.text, isEmpty, reason: 'D1 was logged by the fill'); + }); + + testWidgets('a dish the fill skipped stays queued and prefilled', (t) async { + await pumpHost(t); + await t.runAsync(() async { + await storage.logMeal( + 'own breakfast', + dishNutrition(dish('own', 1)), + slot: 8, + ); + await host.onFillAll(); + await host.onFillAll(); + }); + + // D1's slot was taken, so only D2..D4 landed and D1 is still on offer. + expect(host.status, 'Logged 3 catering dishes.'); + expect(KuchniaQueueService.remaining, 1); + expect(host.desc.text, 'D1'); + }); + + testWidgets('cancel says so and writes nothing', (t) async { + await pumpHost(t); + await t.runAsync(host.onFillAll); + + host.onFillCancel(); + await t.pump(); + + expect(host.status, contains('cancelled')); + expect(host.fillFlow.armedCount, 0); + expect(await t.runAsync(storage.todayEntries), isEmpty); + expect(host.publishes, 0); + }); + + testWidgets('a failure mid-fill publishes what landed and says so', ( + t, + ) async { + await pumpHost(t); + host.fillFlow = FillAllFlow( + fetch: (_) async => KuchniaRefresh(dishes: delivery), + slots: () => defaultSlots, + occupiedSlots: () async => throw Exception('storage unavailable'), + storage: storage, + ); + + await t.runAsync(host.onFillAll); + + expect(host.status, startsWith('Fill all failed')); + expect(host.publishes, 1, reason: 'a partial batch must still sync'); + expect(host.fillFlow.busy, isFalse); + }); +} diff --git a/diet_guard/tests/test_kuchnia_parity.py b/diet_guard/tests/test_kuchnia_parity.py index e34346a..1d27d4d 100644 --- a/diet_guard/tests/test_kuchnia_parity.py +++ b/diet_guard/tests/test_kuchnia_parity.py @@ -15,6 +15,8 @@ What the parity actually protects, per ``docs/kuchnia-wikinga.md``: the curated bank republishes to every peer on every refresh. * **Bank keys and record values.** Divergence there is the same flood by a different route. +* **Fill all with catering.** Which dishes ``fill_plan`` keeps and which + ``log_dishes`` writes, so both devices fill the same empty slots. """ from __future__ import annotations @@ -22,12 +24,14 @@ from __future__ import annotations import json from pathlib import Path from typing import Any +from unittest.mock import MagicMock import pytest +from diet_guard import _kuchnia_log from diet_guard._kuchnia_import import dish_to_record from diet_guard._kuchnia_parse import Dish, parse_menu -from diet_guard._kuchnia_spread import assign_slots +from diet_guard._kuchnia_spread import SlottedDish, assign_slots FIXTURE = Path(__file__).resolve().parents[2] / "tests/fixtures/kuchnia_day.json" @@ -145,3 +149,57 @@ def test_bank_record_numbers_are_floats(dishes: list[Dish]) -> None: # ``count`` is the exception: an int on both sides. assert isinstance(record["count"], int) assert not isinstance(record["count"], bool) + + +def _spread( + dishes: list[Dish], fill: dict[str, Any], case: dict[str, Any] +) -> list[SlottedDish]: + """Re-spread the case's dish subset; never a slice of the full spread.""" + return assign_slots([dishes[i] for i in case["take"]], fill["slots"]) + + +def test_fill_plan_matches( + fixture: dict[str, Any], dishes: list[Dish], monkeypatch: pytest.MonkeyPatch +) -> None: + """Only dishes whose slot is empty today are proposed, doubles included. + + Called through the module so a patched ``fill_plan`` is the one exercised. + """ + fill = fixture["expected"]["fill"] + assert fill["fill_plan"], "fixture carries no fill_plan cases" + for key, case in fill["fill_plan"].items(): + today = [{"slot": slot} for slot in case["occupied"]] + monkeypatch.setattr(_kuchnia_log, "today_entries", lambda t=today: t) + kept = _kuchnia_log.fill_plan(_spread(dishes, fill, case)) + actual = [[item.dish.name, item.slot] for item in kept] + assert actual == case["expected"], f"fill_plan diverged for {key}" + + +def test_log_dishes_matches( + fixture: dict[str, Any], dishes: list[Dish], monkeypatch: pytest.MonkeyPatch +) -> None: + """The same dishes are written, a twin pair in one slot landing once. + + ``log_meal`` is replaced by a recorder, so nothing reaches the log. + """ + fill = fixture["expected"]["fill"] + for key, case in fill["log_dishes"].items(): + recorder = MagicMock() + monkeypatch.setattr(_kuchnia_log, "today_entries", lambda c=case: c["today"]) + monkeypatch.setattr(_kuchnia_log, "log_meal", recorder) + _kuchnia_log.log_dishes(_spread(dishes, fill, case)) + written = [[call.args[0], call.args[2]] for call in recorder.call_args_list] + assert written == case["expected"], f"log_dishes diverged for {key}" + + +def test_twin_case_is_not_vacuous(fixture: dict[str, Any], dishes: list[Dish]) -> None: + """The intra-batch case really puts two identical dishes in one slot. + + Without this, a regenerated fixture whose spread separates the twins would + keep passing while no longer testing the dedup at all. + """ + fill = fixture["expected"]["fill"] + case = fill["log_dishes"]["twins_share_16"] + spread = [(item.dish.name, item.slot) for item in _spread(dishes, fill, case)] + assert spread.count(("Twin dish", 16)) == 2 + assert case["expected"].count(["Twin dish", 16]) == 1 diff --git a/docs/DOCS-kuchnia-wikinga-phone.md b/docs/DOCS-kuchnia-wikinga-phone.md index d86c6f5..e491fc4 100644 --- a/docs/DOCS-kuchnia-wikinga-phone.md +++ b/docs/DOCS-kuchnia-wikinga-phone.md @@ -15,6 +15,30 @@ PC switched off. The files map one-to-one: | `_kuchnia_spread.py` | `services/kuchnia_spread.dart` | | `_kuchnia_import.py` | `services/kuchnia_import.dart` | | `_gatelock_kuchnia.py` | `screens/log_meal_kuchnia_mixin.dart` + `services/kuchnia_queue.dart` | +| `_kuchnia_log.py` (`fill_plan`, `log_dishes`) | `services/kuchnia_fill.dart` | +| `_gatelock_fillall.py` | `services/kuchnia_fill_flow.dart` + `screens/log_meal_fill_mixin.dart` + `widgets/fill_all_row.dart` | + +### "🍱 Fill all with catering" + +The PC gate's Fill all, on the log screen under "Today's delivery". Two taps, +never one: the first fetches (unguarded, like the delivery button) and shows +"Will log N (X kcal): HH:00 dish, …" while the button reads "✓ Confirm (N)"; +only the second writes. Cancel disarms. No modal. + +- **Every slot today, later ones included, skipping occupied slots.** `fillPlan` + takes occupancy as an argument (Python reads `today_entries()` itself), read + once per batch so two dishes spread into one empty slot both land. Confirm + re-reads it: a meal logged between the taps keeps its slot. +- **The plan is pinned to the day of the first tap.** A tap after midnight + refetches instead of confirming yesterday's menu into today. +- **Entries match a submitted prefill** (`nutritionForPortion` with no "per" + basis, so the same 0.1 rounding) except for the source, `kuchnia wikinga`, + the PC's tag. The prefill still tags its entries `catering`. +- **One publish per batch** (`publishAfterLog`, shared with the manual submit): + `autoSync` is single-flight, so publishing per dish would drop the later + pushes. +- Filled dishes leave the delivery queue, and a prefilled form holding one of + them is cleared, so it cannot be submitted a second time. **Android only.** The panel sends no CORS headers, so a browser cannot call it; `refreshDelivery` checks `kIsWeb` first and returns a reason. The settings diff --git a/scripts/build_kuchnia_fixture.py b/scripts/build_kuchnia_fixture.py index 1d97057..b55bfb3 100755 --- a/scripts/build_kuchnia_fixture.py +++ b/scripts/build_kuchnia_fixture.py @@ -29,10 +29,13 @@ from __future__ import annotations import json from pathlib import Path +from typing import Any +from unittest.mock import patch +from diet_guard import _kuchnia_log from diet_guard._kuchnia_import import dish_to_record from diet_guard._kuchnia_parse import Dish, parse_menu -from diet_guard._kuchnia_spread import assign_slots +from diet_guard._kuchnia_spread import SlottedDish, assign_slots FIXTURE = Path(__file__).resolve().parent.parent / "tests/fixtures/kuchnia_day.json" @@ -139,6 +142,57 @@ def _dish_json(dish: Dish) -> dict[str, object]: } +#: "Fill all with catering" cases. ``take`` indexes the parsed ``dishes`` list, +#: re-spread over ``slots`` (never a slice of the full spread). The full day +#: spreads the twins to 16 and 20, so ``twins_share_16`` drops the first dish: +#: 7 on 4 slots puts both twins on 16, the one place intra-batch dedup bites. +_ALL = list(range(8)) +_SLOTS = [8, 12, 16, 20] +_PLAN_CASES = { + "empty_day": {"take": _ALL, "occupied": []}, + "occupied_12": {"take": _ALL, "occupied": [12]}, + # First five in slot order: pancakes and hummus double into 8, both kept. + "five_on_four": {"take": _ALL[:5], "occupied": [16]}, + "full_day": {"take": _ALL, "occupied": _SLOTS}, +} +# Case/space-insensitive name match, and the same name in another slot is no +# match: log_dishes dedups by (name, slot), it does not test occupancy. +_SEEN = [{"desc": " twin DISH ", "slot": 16}, {"desc": "No priority, kept", "slot": 8}] +_LOG_CASES = { + "empty_day": {"take": _ALL, "today": []}, + "twins_share_16": {"take": _ALL[1:], "today": []}, + "twin_logged": {"take": _ALL[1:], "today": _SEEN}, +} + + +def _spread(dishes: list[Dish], case: dict[str, Any]) -> list[SlottedDish]: + """Assign the case's dish subset to the default slots.""" + return assign_slots([dishes[i] for i in case["take"]], _SLOTS) + + +def _fill_cases(dishes: list[Dish]) -> dict[str, object]: + """Run the Python ``fill_plan``/``log_dishes`` over every case. + + Both seams are patched: unpatched, ``log_meal`` writes to the live log. + """ + plans: dict[str, object] = {} + for key, case in _PLAN_CASES.items(): + today = [{"slot": slot} for slot in case["occupied"]] + with patch.object(_kuchnia_log, "today_entries", return_value=today): + kept = _kuchnia_log.fill_plan(_spread(dishes, case)) + plans[key] = {**case, "expected": [[s.dish.name, s.slot] for s in kept]} + logs: dict[str, object] = {} + for key, case in _LOG_CASES.items(): + with ( + patch.object(_kuchnia_log, "today_entries", return_value=case["today"]), + patch.object(_kuchnia_log, "log_meal") as fake, + ): + _kuchnia_log.log_dishes(_spread(dishes, case)) + written = [[call.args[0], call.args[2]] for call in fake.call_args_list] + logs[key] = {**case, "expected": written} + return {"slots": _SLOTS, "fill_plan": plans, "log_dishes": logs} + + def build() -> dict[str, object]: """Return the whole fixture: the payload and its expected results.""" payload = { @@ -172,6 +226,7 @@ def build() -> dict[str, object]: "slot_order": [s.dish.name for s in assign_slots(dishes, default_slots)], "bank_keys": [dish.name.strip().casefold() for dish in dishes], "bank_records": [dish_to_record(dish) for dish in dishes], + "fill": _fill_cases(dishes), }, } diff --git a/tests/fixtures/kuchnia_day.json b/tests/fixtures/kuchnia_day.json index c0ca23e..24fe342 100644 --- a/tests/fixtures/kuchnia_day.json +++ b/tests/fixtures/kuchnia_day.json @@ -168,6 +168,283 @@ } ], "dropped_count": 5, + "fill": { + "fill_plan": { + "empty_day": { + "expected": [ + [ + "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", + 8 + ], + [ + "Hummus paprykowy, mini wafle kukurydziane, warzywa", + 8 + ], + [ + "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", + 12 + ], + [ + "Fit ciasto, jak twix white", + 12 + ], + [ + "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", + 16 + ], + [ + "Twin dish", + 16 + ], + [ + "Twin dish", + 20 + ], + [ + "No priority, kept", + 20 + ] + ], + "occupied": [], + "take": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "five_on_four": { + "expected": [ + [ + "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", + 8 + ], + [ + "Hummus paprykowy, mini wafle kukurydziane, warzywa", + 8 + ], + [ + "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", + 12 + ], + [ + "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", + 20 + ] + ], + "occupied": [ + 16 + ], + "take": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + "full_day": { + "expected": [], + "occupied": [ + 8, + 12, + 16, + 20 + ], + "take": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "occupied_12": { + "expected": [ + [ + "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", + 8 + ], + [ + "Hummus paprykowy, mini wafle kukurydziane, warzywa", + 8 + ], + [ + "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", + 16 + ], + [ + "Twin dish", + 16 + ], + [ + "Twin dish", + 20 + ], + [ + "No priority, kept", + 20 + ] + ], + "occupied": [ + 12 + ], + "take": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + } + }, + "log_dishes": { + "empty_day": { + "expected": [ + [ + "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", + 8 + ], + [ + "Hummus paprykowy, mini wafle kukurydziane, warzywa", + 8 + ], + [ + "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", + 12 + ], + [ + "Fit ciasto, jak twix white", + 12 + ], + [ + "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", + 16 + ], + [ + "Twin dish", + 16 + ], + [ + "Twin dish", + 20 + ], + [ + "No priority, kept", + 20 + ] + ], + "take": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "today": [] + }, + "twin_logged": { + "expected": [ + [ + "Hummus paprykowy, mini wafle kukurydziane, warzywa", + 8 + ], + [ + "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", + 8 + ], + [ + "Fit ciasto, jak twix white", + 12 + ], + [ + "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", + 12 + ], + [ + "No priority, kept", + 20 + ] + ], + "take": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "today": [ + { + "desc": " twin DISH ", + "slot": 16 + }, + { + "desc": "No priority, kept", + "slot": 8 + } + ] + }, + "twins_share_16": { + "expected": [ + [ + "Hummus paprykowy, mini wafle kukurydziane, warzywa", + 8 + ], + [ + "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", + 8 + ], + [ + "Fit ciasto, jak twix white", + 12 + ], + [ + "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", + 12 + ], + [ + "Twin dish", + 16 + ], + [ + "No priority, kept", + 20 + ] + ], + "take": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "today": [] + } + }, + "slots": [ + 8, + 12, + 16, + 20 + ] + }, "slot_order": [ "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", "Hummus paprykowy, mini wafle kukurydziane, warzywa",