mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Every Python consumer now speaks slot minutes:
- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
at every "is this slot logged?" site: gate, CLI status, MCP, catering
occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
legacy int hour, validated against today's slots; get_slots returns
{minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
list, typed times, Up/Down step without posting the popdown), locked
as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
181 lines
7.7 KiB
Python
181 lines
7.7 KiB
Python
"""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._kuchnia_log import dish_nutrition
|
|
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 = [480, 720]
|
|
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_a_prefilled_dish_logs_exactly_its_own_values(self, gate: MealGate) -> None:
|
|
"""What the submit logs is the dish, not the dish scaled by grams/100.
|
|
|
|
The fields alone cannot catch this: they read the right numbers either
|
|
way. The regression it pins is a 350 g dish prefilled with its
|
|
whole-portion macros while the "per" basis sat at the 100 g default,
|
|
so ``Log & Continue`` recorded 3.5x the calories the form showed.
|
|
"""
|
|
dish = _dish("Owsianka")
|
|
gate._delivery_pending = (dish,)
|
|
gate._prefill_next_dish()
|
|
assert gate._current_nutrition() == dish_nutrition(dish)
|
|
assert "→ 391 kcal" in gate._vars.preview.get()
|
|
with (
|
|
patch.object(_gatelock_mealflow, "log_meal") as log,
|
|
patch.object(_gatelock_mealflow, "remember_food"),
|
|
):
|
|
gate._on_submit()
|
|
assert log.call_args.args[1] == dish_nutrition(dish)
|
|
|
|
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()
|