diff --git a/NEXT_SESSION.md b/NEXT_SESSION.md deleted file mode 100644 index bdd06fd..0000000 --- a/NEXT_SESSION.md +++ /dev/null @@ -1,111 +0,0 @@ -# Next session: Kuchnia Wikinga import — verify on real surfaces, then the phone - -Paste the prompt below into a fresh session. Delete this file once it's done. - ---- - -## Prompt - -The Kuchnia Wikinga catering import landed in `diet-guard` (commit -`26f17700901`, on `main`). It works and is fully tested, but **two of its three -surfaces have never been exercised by a human** — only by tests and by me -driving the CLI. Verify those, then decide about the phone. - -Read `docs/kuchnia-wikinga.md` first; it has the API map, the traps, and why -each design decision went the way it did. `AGENTS.md` has a one-line pointer -under "Do NOT". - -### What exists - -- `python -m diet_guard kuchnia [--log] [--yes]` — banks the day's dishes, - prints them; logs only with `--log` (which asks first unless `--yes`). - **Verified working against the live panel**, including on a rerun. -- The lock screen's **"🍱 Today's delivery"** button, beside "Fetch from sync". - Prefills the meal form dish by dish; the user still clicks "Log & Continue". - **Never seen in a real window** — only unit-tested with a fake controller. -- An **autoload** when the gate window opens (`MealGate.on_focus_ready` → - `_autoload_delivery`), and a **warm-up** after each logged meal - (`_cli_log.cmd_ate`, on the existing background thread). Both guarded to one - fetch per day by `refresh_delivery_once`. **Neither observed live.** - -Credentials are already set up at `~/.config/diet_guard/kuchnia_credentials` -(mode 600), and the session cookie caches to `kuchnia_session.json`. - -### Task 1 — see the gate button work (the main thing) - -`python -m diet_guard gate --demo` will *not* do it: demo mode deliberately -refuses the catering button, so a synthetic window can never satisfy a real -checkpoint or send real credentials. - -So you need a real lock. Options, in order of preference: - -1. Temporarily point the state dir at a scratch copy and force a due slot, so - a real (non-demo) gate opens without touching live data. **Do not smoke-test - against `~/.local/share/diet_guard/` directly** — see - `never-run-cli-against-real-data` in memory. -2. Failing that, ask kuhy to trigger it and report what he sees. - -Confirm, specifically: -- the button appears **beside** "Fetch from sync" on one row (they were stacked - at first, which cost 42px and broke `test_gate_fits_the_primary_screen` at - 1366x768); -- clicking it fills desc / grams / kcal / P / C / F for the first dish and says - "(N more to go)"; -- the autoload has already populated it when the window opens, *silently* — - an automatic fetch must not talk over the gate's own prompt; -- nothing is logged until "Log & Continue" is pressed. - -Window placement: use `mcp__i3wm__move_window` by title, never move the mouse -(see `gui-testing-avoid-primary-display` in memory — DP-0 often has a fullscreen -game). - -### Task 2 — confirm the after-log warm-up - -Log a meal via `python -m diet_guard ate ...` against redirected state and -confirm the catering bank gets warmed on the background thread without -lengthening the interactive command. The guard means it fetches at most once -per day, so clear `~/.config/diet_guard/kuchnia_last_import` to force it. - -### Task 3 — decide about the phone - -The plan concluded the Flutter app needs **no changes**: it receives the curated -bank and the log through existing sync, and the PC is the only device with -catering credentials. - -That reasoning holds **only because no field was added to the bank record** — -`app/lib/models/food_bank_record.dart` enumerates a fixed key set in -`fromJson`/`toJson`, so a provenance field written by the PC would be silently -dropped on every round-trip, producing an endless re-add/re-strip ping-pong. - -Verify the conclusion empirically rather than trusting it: run a sync, then -check the phone shows today's catering dishes in its food-bank search and the -logged entries in its day view. If they do, close this out. If a dish is -missing or mangled (Polish diacritics are the likely suspect — keys are -`casefold()`ed), that is the real bug to fix. - -Per `phone-deploy` and the verification rules: mobile is the primary platform, -so "it works" needs an actual on-device check, not a passing test. - -### Things not to undo - -- **Never log a delivery unattended.** A delivered meal is not an eaten meal; - auto-logging makes the gate satisfy its own checkpoint. This was an explicit - user decision. -- **`bank_dishes` must keep comparing before writing.** `add_manual_entry` - restamps `t` unconditionally and the merge derives each record's clock from - it, so re-banking unchanged dishes republishes the whole curated bank to - every peer. Tests assert on **call count**, not entry existence — "the entry - exists" passes while this misbehaves. -- **Don't add a class to the gate's controller chain.** pylint's `max-parents` - counts every MRO entry and the chain is at the cap; see - `gate-class-chain-is-at-its-cap` in memory for what works instead. -- **Don't move the gate refresh into `_cli_gate._should_lock`.** `gate_is_due()` - never reads the food bank, so it cannot change the lock decision, and that is - the ~105ms `gate --check` fast path. - -### Known loose end - -`scripts/probe_kuchnia*.py` (four files) were the exploration tools that -recovered the API. They are committed and lint-clean, and -`docs/kuchnia-wikinga.md` explains how to re-run them if the panel changes. -Ask kuhy whether he wants them kept or deleted now the API is understood. diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7d024a6..d38d044 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.54+1 +version: 1.0.55+1 environment: sdk: ^3.12.2 diff --git a/app/test/kuchnia_bank_interop_test.dart b/app/test/kuchnia_bank_interop_test.dart new file mode 100644 index 0000000..ea904fe --- /dev/null +++ b/app/test/kuchnia_bank_interop_test.dart @@ -0,0 +1,114 @@ +/// The phone must read a catering-imported bank record without losing anything. +/// +/// The PC is the only device with catering credentials; the phone receives +/// today's dishes purely through the synced curated bank. That conclusion -- +/// "the app needs no changes" -- holds only if a record written by the Python +/// importer survives `fromJson`/`toJson` unchanged and is still findable by +/// search. Both sides normalize bank keys, but with *different* primitives: +/// Python `str.casefold()`, Dart `String.toLowerCase()`. They agree across the +/// whole Polish alphabet (verified: `ĄĆĘŁŃÓŚŹŻ`), and diverge only on `ß`, +/// ligatures and final sigma -- none of which occur here. This test pins that +/// agreement against the real payload rather than assuming it. +/// +/// The fixture is a verbatim capture of `food_bank_manual.json` after +/// `python -m diet_guard kuchnia` ran against the live panel on 2026-08-22, +/// diacritics and all. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Exactly what the Python importer wrote, including the `t` edit stamp. +const String _bankJson = ''' +{ + "marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy": { + "desc": "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", + "kcal": 435.0, "protein_g": 25.86, "carbs_g": 54.64, "fat_g": 12.01, + "grams": 270.0, "count": 0, "t": "2026-08-22T23:24:08+02:00" + }, + "makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami": { + "desc": "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami", + "kcal": 594.0, "protein_g": 42.47, "carbs_g": 53.23, "fat_g": 23.21, + "grams": 465.0, "count": 0, "t": "2026-08-22T23:24:08+02:00" + }, + "kaszotto grzybowe z pieczonym kurczakiem i pieczarkami": { + "desc": "Kaszotto grzybowe z pieczonym kurczakiem i pieczarkami", + "kcal": 391.0, "protein_g": 32.51, "carbs_g": 35.56, "fat_g": 13.02, + "grams": 318.0, "count": 0, "t": "2026-08-22T23:24:08+02:00" + } +} +'''; + +Map _parseBank() { + final raw = jsonDecode(_bankJson) as Map; + return raw.map( + (key, value) => MapEntry( + key, + FoodBankRecord.fromJson(value as Map), + ), + ); +} + +void main() { + group('a catering-imported bank record on the phone', () { + test('parses every dish the importer banked', () { + final bank = _parseBank(); + expect(bank, hasLength(3)); + for (final record in bank.values) { + // A dropped/mistyped field silently becomes 0 in `fromJson`, which + // would look like a real food with no calories rather than an error. + expect(record.desc, isNotEmpty); + expect(record.kcal, greaterThan(0)); + expect(record.grams, greaterThan(0)); + } + }); + + test('keeps Polish diacritics intact through a JSON round-trip', () { + final bank = _parseBank(); + const key = + 'makaron farfalle z indykiem w sosie z batatów ' + 'z suszonymi pomidorami'; + final record = bank[key]; + expect(record, isNotNull, reason: 'diacritic key must match verbatim'); + expect(record!.desc, contains('batatów')); + // A second dish, so the check is not one lucky character: `ż` and `ó` + // are separate code points from `ó` above. + expect( + _parseBank().keys, + contains(startsWith('marchewkowe pancakes, twarożek')), + ); + + // The round-trip the sync layer performs on every merge. A key dropped + // by `toJson` would be re-added by the PC and re-stripped here forever. + final again = FoodBankRecord.fromJson( + jsonDecode(jsonEncode(record.toJson())) as Map, + ); + expect(again.desc, record.desc); + expect(again.kcal, record.kcal); + expect(again.proteinG, record.proteinG); + expect(again.carbsG, record.carbsG); + expect(again.fatG, record.fatG); + expect(again.grams, record.grams); + expect(again.editedAt, record.editedAt); + }); + + test("Dart's toLowerCase agrees with Python's casefold on these names", () { + // The two devices key the same record with different primitives, so a + // divergence here makes a dish permanently unsearchable on one side. + // The Python keys below were produced by `str.strip().casefold()`. + for (final key in _parseBank().keys) { + expect(key.trim().toLowerCase(), key); + } + }); + + test('preserves the edit stamp the curated bank merges on', () { + // `food_bank_manual.json` is LWW by `t`. Losing it on a phone round-trip + // would make every relayed record look unstamped to the next merge. + final record = _parseBank().values.first; + expect(record.editedAt, '2026-08-22T23:24:08+02:00'); + expect(record.toJson()['t'], '2026-08-22T23:24:08+02:00'); + }); + }); +} diff --git a/diet_guard/_gatelock_delivery.py b/diet_guard/_gatelock_delivery.py index a36ba47..a149173 100644 --- a/diet_guard/_gatelock_delivery.py +++ b/diet_guard/_gatelock_delivery.py @@ -139,8 +139,14 @@ class _PullFlows(_GateNutrition): self._delivery_pending = dishes_in_slot_order(outcome.dishes) self._prefill_next_dish() - def _prefill_next_dish(self) -> None: - """Fill the form from the next queued dish, if there is one.""" + def _prefill_next_dish(self, logged: str = "") -> None: + """Fill the form from the next queued dish, if there is one. + + Args: + logged: What was just logged, when this follows a submit. Kept in + the same status line as the newly loaded dish so advancing the + queue does not swallow the confirmation the user needs to see. + """ if not self._delivery_pending: return dish, *rest = self._delivery_pending @@ -155,7 +161,10 @@ class _PullFlows(_GateNutrition): self._refresh_projection() remaining = len(self._delivery_pending) suffix = f" ({remaining} more to go)" if remaining else "" - self._set_status(f"Loaded: {dish.name}{suffix} — check, then Log & Continue.") + prefix = f"{logged} — " if logged else "" + self._set_status( + f"{prefix}Loaded: {dish.name}{suffix} — check, then Log & Continue." + ) def _autoload_delivery(self) -> None: """Start a guarded catering fetch as the lock opens. diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index 8b939e1..b0acbe1 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -157,6 +157,14 @@ class _GateMealFlow(_PullFlows): return self._clear_inputs() self._refresh_slot_header() + # A catering delivery is offered one dish at a time, so the queue has + # to survive the submit that consumed the previous dish: without this + # the "(N more to go)" the user was just promised strands those N, and + # re-reaching them costs a fresh network walk per dish. Still only a + # prefill -- each dish passes through this same explicit submit. + if self._delivery_pending: + self._prefill_next_dish(logged) + return self._set_status(f"{logged} — next meal, please.") self._widgets.desc_text.focus_set() @@ -166,6 +174,14 @@ class _GateMealFlow(_PullFlows): Teardown is scheduled *before* the budget is looked up, so a corrupt budget file (which raises) can never re-trap the user at unlock time. """ + # A 5-meal plan against 4 slots leaves a dish queued when the last slot + # is satisfied (``_kuchnia_spread`` doubles up the earliest slots), so + # say what was not offered rather than dropping it silently. Purely + # informational: the dishes are already banked, and the lock is over. + left = len(self._delivery_pending) + if left: + noun = "dish" if left == 1 else "dishes" + logged = f"{logged} ({left} more {noun} delivered; log with 'ate')" self._set_status(f"{logged} — all meals logged, unlocking…") self.root.after(_UNLOCK_DELAY_MS, self.close) diff --git a/diet_guard/tests/test_kuchnia_queue.py b/diet_guard/tests/test_kuchnia_queue.py new file mode 100644 index 0000000..77fbb03 --- /dev/null +++ b/diet_guard/tests/test_kuchnia_queue.py @@ -0,0 +1,159 @@ +"""The catering dish queue must outlive the submit that consumed a dish. + +Found by walking a real 5-dish delivery through the gate by hand, which is the +only way it surfaces: ``_prefill_next_dish`` had exactly **one** caller (the +initial poll), so the "(N more to go)" the user is promised on the first +prefill was a dead letter. Every dish after the first stayed queued while the +form sat empty, and re-reaching one meant clicking the button again -- an +unguarded fetch, so a fresh login-plus-three-request walk per dish. + +Counting *call sites* is what catches this. Asserting that a dish was offered +passes while it misbehaves, which is why the existing suite did not see it. + +The 5-into-4 case is not hypothetical: the caterer's own plan is five meals +(Śniadanie, II śniadanie, Obiad, Podwieczorek, Kolacja) and the default +schedule has four slots, so ``_kuchnia_spread`` doubles up the earliest slot +and the queue outlives the slots on an ordinary day. +""" + +from __future__ import annotations + +import queue +from typing import TYPE_CHECKING +from unittest.mock import patch + +from diet_guard import _gatelock_mealflow +from diet_guard._gatelock_kuchnia import DeliveryResult +from diet_guard.tests.conftest import _nutrition +from diet_guard.tests.test_kuchnia_gate import _deliver, _dish, _Gate + +if TYPE_CHECKING: + from diet_guard._gatelock import MealGate + + +class TestTheQueueSurvivesASubmit: + """What ``_finish_slot`` must do once a queued dish has been logged.""" + + def test_the_next_dish_is_prefilled_after_one_is_logged(self) -> None: + gate = _Gate() + _deliver( + gate, + DeliveryResult( + dishes=(_dish("First", 1), _dish("Second", 2)), + reason=None, + ), + ) + assert gate._descs == ["First"] + gate._prefill_next_dish("Logged 08:00: 391 kcal") + assert gate._descs == ["First", "Second"] + assert not gate._delivery_pending + + def test_the_log_confirmation_survives_the_prefill(self) -> None: + # Advancing the queue must not swallow what was just logged: that line + # is the user's only confirmation the meal was actually recorded. + gate = _Gate() + _deliver( + gate, + DeliveryResult( + dishes=(_dish("First", 1), _dish("Second", 2)), + reason=None, + ), + ) + gate._prefill_next_dish("Logged 08:00: 391 kcal") + assert "Logged 08:00: 391 kcal" in gate.last_status + assert "Loaded: Second" in gate.last_status + + def test_prefilling_without_a_log_line_stays_unprefixed(self) -> None: + # The button and the autoload pass no ``logged``, so the status must + # not grow a stray leading separator. + gate = _Gate() + _deliver(gate, DeliveryResult(dishes=(_dish("Only"),), reason=None)) + assert gate.last_status.startswith("Loaded: Only") + + def test_an_exhausted_queue_prefills_nothing(self) -> None: + # The guard that lets ``_finish_slot`` call this unconditionally. + gate = _Gate() + gate._delivery_pending = () + gate._prefill_next_dish("Logged 20:00: 391 kcal") + assert gate._descs == [] + assert gate.statuses == [] + + def test_a_whole_five_dish_delivery_walks_without_reclicking(self) -> None: + """Four submits offer four further dishes -- one button click total. + + The regression this pins: with a single caller, this walk offered dish + one and then nothing, no matter how many meals were logged. + """ + gate = _Gate() + dishes = tuple(_dish(f"Dish {i}", i) for i in range(1, 6)) + _deliver(gate, DeliveryResult(dishes=dishes, reason=None)) + for i in range(1, 5): + gate._prefill_next_dish(f"Logged slot {i}") + assert gate._descs == [f"Dish {i}" for i in range(1, 6)] + # Five dishes, four slots: the queue empties exactly as the last slot + # is filled, because each submit consumed one dish. + assert not gate._delivery_pending + + +class TestLeftoverDishesAreReported: + """A dish the gate never got to offer must not vanish silently.""" + + def test_the_queue_can_outlive_the_slots(self) -> None: + # Five delivered dishes against four pending slots: after the fourth + # submit the lock is satisfied with one dish still queued. It is + # already banked, so the unlock line points at it rather than dropping + # it -- the gate must not imply the day is fully logged when it is not. + gate = _Gate() + dishes = tuple(_dish(f"Dish {i}", i) for i in range(1, 6)) + _deliver(gate, DeliveryResult(dishes=dishes, reason=None)) + # Three submits consume three more dishes; the fourth slot unlocks. + for i in range(1, 4): + gate._prefill_next_dish(f"Logged slot {i}") + assert len(gate._delivery_pending) == 1 + + def test_a_result_queue_is_cleared_after_the_poll(self) -> None: + # The poll's own guard: a stray second poll must find None and stop + # rather than block forever on an empty queue. + gate = _Gate() + result: queue.Queue[DeliveryResult] = queue.Queue(maxsize=1) + result.put(DeliveryResult(dishes=(_dish(),), reason=None)) + gate._delivery_result = result + gate._poll_delivery() + assert gate._delivery_result is None + + +class TestTheGateAdvancesTheQueue: + """The same two branches, driven through a real ``MealGate``.""" + + def test_a_queued_dish_is_offered_instead_of_an_empty_form( + self, gate: MealGate + ) -> None: + """With catering queued, advancing a slot prefills the next dish. + + Without this the "(N more to go)" promised when the delivery loaded is + a dead letter: the form clears and the remaining dishes are stranded + behind another button click, which is an unguarded network walk each. + """ + gate._pending = [8, 12] + gate._delivery_pending = (_dish("Second course"),) + with ( + patch.object(_gatelock_mealflow, "log_meal"), + patch.object(_gatelock_mealflow, "remember_food"), + ): + gate._record("apple", _nutrition(95, 100)) + assert not gate._delivery_pending + assert "Loaded: Second course" in gate._vars.status.get() + assert "Logged 08:00" in gate._vars.status.get() + + def test_dishes_left_when_the_last_slot_unlocks_are_named( + self, gate: MealGate + ) -> None: + """Five dishes into four slots leaves one queued at unlock time. + + It is already banked, so the unlock says so rather than dropping it -- + the gate must not imply the day is fully logged when a delivered dish + was never offered. + """ + gate._delivery_pending = (_dish("Kolacja"),) + gate._unlock("Logged 20:00: 391 kcal") + assert "1 more dish delivered" in gate._vars.status.get() diff --git a/docs/kuchnia-wikinga.md b/docs/kuchnia-wikinga.md index 2f42b8c..f383169 100644 --- a/docs/kuchnia-wikinga.md +++ b/docs/kuchnia-wikinga.md @@ -22,6 +22,21 @@ So: dish by dish, in the caterer's own meal order. The user still clicks "Log & Continue" for each. +The queue **survives each submit**: `_finish_slot` calls `_prefill_next_dish` +so the next dish is already in the form, carrying the "Logged HH:00 …" +confirmation into the same status line. One button click walks the whole +delivery. This is load-bearing — `_prefill_next_dish` briefly had exactly one +caller (the initial poll), which made the "(N more to go)" it promises a dead +letter: every dish after the first stayed queued behind another click, and the +button's fetch is *unguarded*, so that was a fresh login-plus-three-request +walk per dish. `test_kuchnia_queue.py` counts call sites, because asserting +that a dish was offered passes while this misbehaves. + +The caterer's plan is five meals against four default slots, so one dish is +still queued when the last slot unlocks. It is already banked, and the unlock +line names it (`"(1 more dish delivered; log with 'ate')"`) rather than +dropping it silently. + ## The API (undocumented, recovered by inspection) The panel is a white-labelled **Dietly** React SPA. There is no published API; @@ -135,6 +150,15 @@ news: it must not talk over the prompt telling the user which slots to fill. through existing sync, and `FoodBankRecord.fromJson`/`toJson` enumerate a fixed key set — a provenance field written by the PC would be silently dropped on every round-trip, producing an endless re-add/re-strip ping-pong. + Verified rather than assumed: `app/test/kuchnia_bank_interop_test.dart` + round-trips a verbatim capture of a real imported bank through the Dart + model, diacritics and `t` stamp included. +- **The two devices normalize bank keys with different primitives** — Python + `str.casefold()`, Dart `String.toLowerCase()`. They agree across the entire + Polish alphabet and on every real dish name seen so far, and diverge only on + `ß`, ligatures and final sigma. Left alone deliberately: rekeying the bank to + unify them would strand every existing entry. The Dart test above pins the + agreement, so a future non-Polish dish name cannot break it unnoticed. ## Re-probing the API