mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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
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:
parent
a7e3685963
commit
5cd89a4e99
79
app/lib/screens/log_meal_fill_mixin.dart
Normal file
79
app/lib/screens/log_meal_fill_mixin.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
|
||||
106
app/lib/services/kuchnia_fill.dart
Normal file
106
app/lib/services/kuchnia_fill.dart
Normal 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.';
|
||||
}
|
||||
148
app/lib/services/kuchnia_fill_flow.dart
Normal file
148
app/lib/services/kuchnia_fill_flow.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
77
app/lib/widgets/fill_all_row.dart
Normal file
77
app/lib/widgets/fill_all_row.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
91
app/test/fill_all_row_test.dart
Normal file
91
app/test/fill_all_row_test.dart
Normal 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]);
|
||||
});
|
||||
}
|
||||
40
app/test/kuchnia_fill_cancel_test.dart
Normal file
40
app/test/kuchnia_fill_cancel_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
250
app/test/kuchnia_fill_flow_test.dart
Normal file
250
app/test/kuchnia_fill_flow_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
126
app/test/kuchnia_fill_parity_test.dart
Normal file
126
app/test/kuchnia_fill_parity_test.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
57
app/test/kuchnia_fill_support.dart
Normal file
57
app/test/kuchnia_fill_support.dart
Normal 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}',
|
||||
];
|
||||
147
app/test/kuchnia_fill_test.dart
Normal file
147
app/test/kuchnia_fill_test.dart
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
189
app/test/screens/log_meal_fill_mixin_test.dart
Normal file
189
app/test/screens/log_meal_fill_mixin_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
277
tests/fixtures/kuchnia_day.json
vendored
277
tests/fixtures/kuchnia_day.json
vendored
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user