Update app/diet_guard (17 files)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 13:49:58 +02:00
parent a7e3685963
commit 5cd89a4e99
18 changed files with 1770 additions and 18 deletions

View File

@ -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<T extends StatefulWidget>
on LogMealSyncMixin<T>, LogMealKuchniaMixin<T> {
/// 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<void> 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<void> _afterFill(List<SlottedDish> 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();
}
}

View File

@ -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<LogMealScreen>
LogMealSyncMixin<LogMealScreen>,
LogMealNavMixin<LogMealScreen>,
LogMealKuchniaMixin<LogMealScreen>,
LogMealFillMixin<LogMealScreen>,
LogMealFutureMixin<LogMealScreen> {
@override
http.Client? get syncHttpClient => widget.httpClient;
@ -113,6 +115,11 @@ class _LogMealScreenState extends State<LogMealScreen>
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<LogMealScreen>
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<LogMealScreen>
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.

View File

@ -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<T extends StatefulWidget> on State<T>
// 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<DayLog> 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<void> refreshSlots() async {
final logged = await LogStorageService.instance.loggedSlotsToday();

View File

@ -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<SlottedDish> fillPlan(List<SlottedDish> slotted, Set<int> 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<List<SlottedDish>> logDishes(
List<SlottedDish> 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 = <SlottedDish>[];
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<SlottedDish> plan) {
final total = plan.fold<double>(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.';
}

View File

@ -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<KuchniaRefresh> Function(DateTime day);
/// What one tap produced: a line for the user, and what (if anything) landed.
typedef FillOutcome = ({String message, List<SlottedDish> written});
/// The empty "nothing written" half of a [FillOutcome].
const _none = <SlottedDish>[];
/// 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<KuchniaRefresh> _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<int> Function()? slots,
Future<Set<int>> 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<int> Function() _slots;
final Future<Set<int>> Function() _occupied;
final LogStorageService? _storage;
List<SlottedDish> _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<FillOutcome?> 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<FillOutcome?> _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<FillOutcome> _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;
}
}

View File

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

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.103+1
version: 1.0.104+1
environment:
sdk: ^3.12.2

View File

@ -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<void> _pump(
WidgetTester tester, {
bool canFetch = true,
bool busy = false,
int armed = 0,
Future<void> 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<OutlinedButton>(find.byType(OutlinedButton)).onPressed,
isNull,
);
expect(t.widget<TextButton>(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]);
});
}

View File

@ -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<void>();
final flow = FillAllFlow(
fetch: (_) async {
await gate.future;
return KuchniaRefresh(dishes: dishes(4));
},
now: () => DateTime(2026, 8, 23, 10),
slots: () => defaultSlots,
occupiedSlots: () async => <int>{},
storage: freshStorage(),
);
final pending = flow.tap();
flow.cancel();
gate.complete();
expect(await pending, isNull);
expect(flow.armedCount, 0);
});
}

View File

@ -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<DateTime> days = [];
Completer<void>? gate;
Future<KuchniaRefresh> 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<int> occupied;
late int occupancyReads;
Completer<void>? 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<void>();
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<void>();
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<void>();
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);
});
}

View File

@ -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<List<Object?>> _pairs(Iterable<SlottedDish> items) => [
for (final item in items) [item.dish.name, item.slot],
];
void main() {
late Map<String, dynamic> fill;
late List<KuchniaDish> dishes;
setUpAll(() {
final file = File('${_repoRoot.path}/tests/fixtures/kuchnia_day.json');
final fixture = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
fill =
(fixture['expected'] as Map<String, dynamic>)['fill']
as Map<String, dynamic>;
dishes = parseMenu(fixture['payload']);
});
setUp(() => LogStorageService.resetForTesting(store: MemoryDocStore()));
tearDown(LogStorageService.resetForTesting);
List<SlottedDish> spread(Map<String, dynamic> testCase) => assignSlots([
for (final i in testCase['take'] as List) dishes[i as int],
], (fill['slots'] as List).cast<int>());
Map<String, dynamic> cases(String group) =>
fill[group] as Map<String, dynamic>;
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<String, dynamic>;
final occupied = (testCase['occupied'] as List).cast<int>().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<String, dynamic>;
final seeded = testCase['today'] as List;
for (final row in seeded.cast<Map<String, dynamic>>()) {
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<String, dynamic>()));
bool isTwin16(List<Object?> p) => p[0] == 'Twin dish' && p[1] == 16;
expect(input.where(isTwin16), hasLength(2));
expect(
(testCase['expected'] as List).cast<List<Object?>>().where(isTwin16),
hasLength(1),
);
});
}

View File

@ -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<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 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<KuchniaDish> 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<List<String>> loggedToday(LogStorageService storage) async => [
for (final e in await storage.todayEntries()) '${e.desc}@${e.slot}',
];

View File

@ -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<String> _names(List<SlottedDish> 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.',
);
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

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