mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
fix: keep the catering dish queue alive across a submit
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Walking a real 5-dish delivery through the gate by hand showed the "(N more to go)" the lock promises was a dead letter: `_prefill_next_dish` had exactly one caller (the initial poll), so logging the first dish cleared the form and stranded the rest. Reaching them meant clicking "Today's delivery" again -- and the button's fetch is unguarded, so that was a fresh login-plus-three-request walk per dish. `_finish_slot` now prefills the next queued dish, carrying the "Logged HH:00 ..." confirmation into the same status line so advancing the queue does not swallow it. The caterer plans five meals against four default slots, so a dish is still queued when the last slot unlocks. It is already banked, so `_unlock` names it rather than dropping it silently. Also pins the phone conclusion that was previously reasoning only: a verbatim capture of a real imported bank now round-trips through `FoodBankRecord` in `app/test/kuchnia_bank_interop_test.dart`, covering the diacritics and the `t` stamp, and the casefold/toLowerCase agreement the two devices depend on. Verified against the live panel with state redirected to a scratch HOME (real ~/.local/share/diet_guard byte-identical before and after): the autoload prefills silently on window open, a failed autoload stays silent while the button reports, the after-log warm-up runs off-thread (0.62s vs 0.28s guarded) and nothing is logged until "Log & Continue". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
parent
6866863644
commit
0ca95eda5b
111
NEXT_SESSION.md
111
NEXT_SESSION.md
@ -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.
|
||||
@ -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
|
||||
|
||||
114
app/test/kuchnia_bank_interop_test.dart
Normal file
114
app/test/kuchnia_bank_interop_test.dart
Normal file
@ -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<String, FoodBankRecord> _parseBank() {
|
||||
final raw = jsonDecode(_bankJson) as Map<String, dynamic>;
|
||||
return raw.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
FoodBankRecord.fromJson(value as Map<String, dynamic>),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<String, dynamic>,
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
159
diet_guard/tests/test_kuchnia_queue.py
Normal file
159
diet_guard/tests/test_kuchnia_queue.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user