diet-guard/diet_guard/tests/test_kuchnia_queue.py
Krzysztof kuhy Rudnicki 43e4b4070d Replace PC gate text with glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:35:34 +01:00

183 lines
7.8 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_glyphs as g
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,
),
)
logged = f"{g.OK} 08:00 391 kcal"
gate._prefill_next_dish(logged)
assert logged in gate.last_status
assert f"{g.DELIVERY} 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(f"{g.DELIVERY} 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 f"{g.DELIVERY} Second course" in gate._vars.status.get()
assert f"{g.OK} 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(f"{g.OK} 20:00 391 kcal")
assert f"{g.DELIVERY} +1" in gate._vars.status.get()