testsAndMisc/python_pkg/diet_guard/tests/test_gatelock.py
Krzysztof kuhy Rudnicki 31992b2a90 feat(diet_guard): add meal-logging screen-lock gate with trigger fix
Add the diet_guard package: a screen-locking meal-logging gate that fires
on 4-hour slots (08/12/16/20) and records calories/macros, persisting an
autocompleting food bank.

- Trigger fix: the systemd timer fires at session start (Persistent=true)
  before lightdm has written ~/.Xauthority, so the gate crashed with a
  TclError instead of locking the screen. Add wait_for_display() /
  _display_is_ready() in _gatelock.py and wire it into _cli._cmd_gate so the
  gate retries on the next tick instead of crashing; add
  Environment=XAUTHORITY=%h/.Xauthority to the service as belt-and-suspenders.
- Food-bank hardening: a transiently corrupt food_bank.json was warned about
  on every keystroke and then silently overwritten (data loss). _read_bank
  now quarantines it via _quarantine_corrupt_bank() (warn-once + timestamped
  backup) before starting fresh.
- Multi-item meals: new _meal.py (MealItem, meal_total, MEAL_SOURCE),
  remember_meal() + _upsert() in _foodbank.py, and a "+ Add item" control in
  the gate that logs both the individual items and the composite meal.
- Bundle resolve_nutrition's manual macros into a ManualMacros dataclass to
  stay within the argument-count limit.

diet_guard at 100% branch coverage; full pre-commit suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:32:39 +02:00

918 lines
34 KiB
Python

"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window.
A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
real ``TclError``) replaces the conftest's blanket MagicMock for the duration of
each gate test, so the window's *logic* runs for real against in-memory widgets
without ever opening a window or grabbing the keyboard.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.diet_guard import _gatelock
from python_pkg.diet_guard._budget import seal_budget
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._gatelock import (
MealGate,
_format_preview,
_pending_slots,
_safe_float,
acquire_gate_lock,
release_gate_lock,
wait_for_display,
)
from python_pkg.diet_guard._meal import MealItem
# Captured before any autouse fixture patches the module attribute, so the real
# class (not the conftest MagicMock) is available for its callback-error test.
_REAL_GATE_ROOT = _gatelock._GateRoot
class _FakeTclError(Exception):
"""Stand-in for ``tkinter.TclError`` (a real, catchable exception)."""
class FakeVar:
"""A functional ``StringVar``: stores and returns a string."""
def __init__(self, master: object = None, value: str = "") -> None:
self._value = value
def get(self) -> str:
return self._value
def set(self, value: str) -> None:
self._value = value
class FakeEntry:
"""A functional one-line entry (delete clears, insert appends)."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._value = ""
def get(self) -> str:
return self._value
def delete(self, first: object, last: object = None) -> None:
self._value = ""
def insert(self, index: object, text: str) -> None:
self._value += text
def pack(self, *args: object, **kwargs: object) -> FakeEntry:
return self
def bind(self, *args: object, **kwargs: object) -> None:
pass
def configure(self, *args: object, **kwargs: object) -> None:
pass
config = configure
def focus_set(self) -> None:
pass
def focus_force(self) -> None:
pass
class FakeText(FakeEntry):
"""A functional multi-line text box (``get`` ignores the index range)."""
def get(self, start: object = None, end: object = None) -> str:
return self._value
class FakeListbox:
"""A functional listbox tracking items and the current selection."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._items: list[str] = []
self._sel: tuple[int, ...] = ()
def delete(self, first: object, last: object = None) -> None:
self._items = []
def insert(self, index: object, text: str) -> None:
self._items.append(text)
def curselection(self) -> tuple[int, ...]:
return self._sel
def selection_set(self, index: int) -> None:
self._sel = (index,)
def selection_clear(self, first: object, last: object = None) -> None:
self._sel = ()
def pack(self, *args: object, **kwargs: object) -> FakeListbox:
return self
def bind(self, *args: object, **kwargs: object) -> None:
pass
class FakeWidget:
"""A generic no-op widget for Frame/Label/Button/OptionMenu."""
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
return self
def place(self, *args: object, **kwargs: object) -> FakeWidget:
return self
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
return self
config = configure
def bind(self, *args: object, **kwargs: object) -> None:
pass
_FAKE_TK = SimpleNamespace(
END="end",
TclError=_FakeTclError,
StringVar=FakeVar,
Frame=FakeWidget,
Label=FakeWidget,
Button=FakeWidget,
OptionMenu=FakeWidget,
Entry=FakeEntry,
Text=FakeText,
Listbox=FakeListbox,
Event=object,
)
@pytest.fixture
def gate() -> object:
"""Build a demo gate whose widgets are functional fakes."""
with patch.object(_gatelock, "tk", _FAKE_TK):
yield MealGate(demo_mode=True)
def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition:
"""A simple reference nutrition for driving the form."""
return Nutrition(kcal, 10, 20, 5, grams, "food bank")
# --------------------------------------------------------------------------
# Module-level helpers
# --------------------------------------------------------------------------
class TestModuleHelpers:
"""Pure functions and the single-instance lock."""
def test_safe_float_blank(self) -> None:
"""A blank string is None."""
assert _safe_float("") is None
def test_safe_float_number(self) -> None:
"""A numeric string parses."""
assert _safe_float("3.5") == 3.5
def test_safe_float_non_numeric(self) -> None:
"""A non-numeric string is None."""
assert _safe_float("abc") is None
def test_format_preview_with_portion(self) -> None:
"""A non-zero portion shows the grams segment."""
text = _format_preview(_nutrition(grams=200))
assert "200g" in text
def test_format_preview_without_portion(self) -> None:
"""A zero portion omits the grams segment."""
text = _format_preview(_nutrition(grams=0))
assert "g ·" not in text
def test_gate_lock_single_instance(self) -> None:
"""A second acquire while the first is held returns None."""
first = acquire_gate_lock()
assert first is not None
assert acquire_gate_lock() is None
release_gate_lock(first)
def test_pending_slots_due(self) -> None:
"""When slots are due, those are returned verbatim."""
with patch.object(_gatelock, "due_slots", return_value=[12, 16]):
assert _pending_slots(demo_mode=False) == [12, 16]
def test_pending_slots_demo_fallback(self) -> None:
"""Demo mode invents a representative slot when nothing is due."""
with patch.object(_gatelock, "due_slots", return_value=[]):
assert len(_pending_slots(demo_mode=True)) == 1
def test_pending_slots_production_empty(self) -> None:
"""Production with nothing due returns no slots."""
with patch.object(_gatelock, "due_slots", return_value=[]):
assert not _pending_slots(demo_mode=False)
class TestAssertNotUnderPytest:
"""The safety net that blocks a real Tk gate under pytest."""
def test_raises_with_real_tkinter(self) -> None:
"""Real tkinter under pytest is refused."""
with (
patch.object(_gatelock, "tk", SimpleNamespace(__name__="tkinter")),
pytest.raises(RuntimeError),
):
_gatelock._assert_not_under_pytest()
def test_passes_with_mock(self) -> None:
"""A mocked tk (name != tkinter) passes straight through."""
with patch.object(_gatelock, "tk", SimpleNamespace(__name__="mock")):
_gatelock._assert_not_under_pytest()
class TestGateRootCallback:
"""The root's callback-exception routing."""
def test_routes_to_handler(self) -> None:
"""A set handler is invoked on a callback error."""
root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT)
root.on_callback_error = MagicMock()
_REAL_GATE_ROOT.report_callback_exception(
root, ValueError, ValueError("x"), None
)
root.on_callback_error.assert_called_once()
def test_no_handler_is_safe(self) -> None:
"""With no handler set, the error is just logged."""
root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT)
root.on_callback_error = None
_REAL_GATE_ROOT.report_callback_exception(
root, ValueError, ValueError("x"), None
)
# --------------------------------------------------------------------------
# Construction
# --------------------------------------------------------------------------
class TestConstruction:
"""Building the window in both modes."""
def test_demo_builds(self, gate: MealGate) -> None:
"""A demo gate constructs with a pending slot and grams basis."""
assert gate.demo_mode is True
assert gate._unit.get() == "grams"
def test_production_builds(self) -> None:
"""A production gate disables VT switching and grabs input."""
with (
patch.object(_gatelock, "tk", _FAKE_TK),
patch.object(_gatelock.shutil, "which", return_value=None),
):
gate = MealGate(demo_mode=False)
assert gate.demo_mode is False
# --------------------------------------------------------------------------
# Form logic
# --------------------------------------------------------------------------
class TestFormBasics:
"""Field helpers and the numeric validator."""
def test_numeric_validator(self, gate: MealGate) -> None:
"""Blank and numbers are allowed; words are not."""
assert gate._is_numeric_or_blank("")
assert gate._is_numeric_or_blank("12.5")
assert not gate._is_numeric_or_blank("abc")
def test_desc_get_set(self, gate: MealGate) -> None:
"""The description round-trips through its helpers, trimmed."""
gate._set_desc(" shoarma ")
assert gate._get_desc() == "shoarma"
def test_desc_return_suppresses_newline(self, gate: MealGate) -> None:
"""Enter in the description submits and returns the break sentinel."""
gate._set_desc("apple")
with patch.object(gate, "_on_submit") as submit:
assert gate._on_desc_return(None) == "break"
submit.assert_called_once()
def test_macro_values_non_numeric(self, gate: MealGate) -> None:
"""A non-numeric macro field makes the whole read None."""
gate._kcal_entry.insert(0, "abc")
assert gate._macro_values() is None
class TestReferenceModel:
"""The reference -> total nutrition computation."""
def test_reference_none_without_calories(self, gate: MealGate) -> None:
"""No calories typed means no reference yet."""
assert gate._reference_nutrition() is None
def test_current_is_reference_without_amount(self, gate: MealGate) -> None:
"""With calories but no amount, the reference stands in as the total."""
gate._kcal_entry.insert(0, "200")
current = gate._current_nutrition()
assert current is not None
assert current.kcal == 200
def test_current_scales_with_amount(self, gate: MealGate) -> None:
"""Grams eaten scale the per-100 g reference into the total."""
gate._kcal_entry.insert(0, "200")
gate._amount_entry.insert(0, "200")
current = gate._current_nutrition()
assert current is not None
assert current.kcal == 400
class TestSuggestions:
"""Autocomplete population and selection."""
def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None:
"""In items mode, typing a staple fills the per-item weight."""
gate._unit.set("items")
gate._set_desc("apple")
gate._on_desc_keyrelease(None)
assert gate._per_entry.get() == "182"
def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None:
"""Picking a banked suggestion adopts its name and macros."""
gate._suggestions = [("apple pie", _nutrition(300, 120))]
gate._suggestion_mode = "bank"
gate._suggestion_box.selection_set(0)
gate._on_suggestion_select(None)
assert gate._get_desc() == "apple pie"
assert gate._kcal_entry.get() == "300"
def test_select_candidate_keeps_description(self, gate: MealGate) -> None:
"""An OFF candidate fills macros but not the typed description."""
gate._set_desc("my dish")
gate._suggestions = [("openfoodfacts: X", _nutrition(250, 100))]
gate._suggestion_mode = "candidates"
gate._suggestion_box.selection_set(0)
gate._on_suggestion_select(None)
assert gate._get_desc() == "my dish"
def test_select_no_selection(self, gate: MealGate) -> None:
"""No selection is a no-op."""
gate._on_suggestion_select(None)
def test_select_out_of_range(self, gate: MealGate) -> None:
"""A stale selection index beyond the list is ignored."""
gate._suggestions = []
gate._suggestion_box.selection_set(5)
gate._on_suggestion_select(None)
class TestUnitToggle:
"""Switching the grams/items basis."""
def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None:
"""A picked food is re-expressed per item, then back per 100 g."""
gate._apply_reference(_nutrition(52, 100), name="apple")
gate._unit.set("items")
gate._on_unit_change("items")
per_item = gate._kcal_entry.get()
gate._unit.set("grams")
gate._on_unit_change("grams")
assert gate._kcal_entry.get() == "52"
assert per_item != "52"
def test_toggle_without_reference_clears(self, gate: MealGate) -> None:
"""With no picked food, a toggle clears the macro fields."""
gate._kcal_entry.insert(0, "123")
gate._last_reference = None
gate._unit.set("items")
gate._on_unit_change("items")
assert gate._kcal_entry.get() == ""
def test_macro_edit_drops_reference(self, gate: MealGate) -> None:
"""Hand-editing a macro invalidates the stored reference."""
gate._last_reference = _nutrition()
gate._on_macro_edit(None)
assert gate._last_reference is None
class TestSubmit:
"""The two-step submit (look up, then log)."""
def test_empty_description(self, gate: MealGate) -> None:
"""Submitting with no description prompts for one."""
gate._on_submit()
assert "Type what you ate" in gate._status.get()
def test_non_numeric_macros(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before logging."""
gate._set_desc("apple")
gate._kcal_entry.insert(0, "abc")
gate._on_submit()
assert "must be numbers" in gate._status.get()
def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None:
"""A blank calorie field looks the food up rather than logging."""
gate._set_desc("apple")
with patch.object(gate, "_begin_lookup") as lookup:
gate._on_submit()
lookup.assert_called_once()
def test_defensive_none_nutrition(self, gate: MealGate) -> None:
"""A calorie value but unresolvable nutrition prompts again (guard)."""
gate._set_desc("apple")
gate._kcal_entry.insert(0, "200")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_submit()
assert "Enter the calories" in gate._status.get()
def test_valid_submit_records(self, gate: MealGate) -> None:
"""A described, priced meal is recorded."""
gate._set_desc("apple")
gate._kcal_entry.insert(0, "95")
with patch.object(gate, "_record") as record:
gate._on_submit()
record.assert_called_once()
def test_on_return_submits(self, gate: MealGate) -> None:
"""Enter in a numeric field submits."""
with patch.object(gate, "_on_submit") as submit:
gate._on_return(None)
submit.assert_called_once()
class TestLookup:
"""Step one: filling the form from a lookup."""
def test_no_candidates(self, gate: MealGate) -> None:
"""No match asks for a manual value."""
gate._set_desc("nonsense")
with patch.object(_gatelock, "lookup_candidates", return_value=[]):
gate._begin_lookup("nonsense")
assert "Couldn't look that up" in gate._status.get()
def test_single_candidate(self, gate: MealGate) -> None:
"""A single match fills the fields and invites review."""
with patch.object(
_gatelock,
"lookup_candidates",
return_value=[("apple", _nutrition(95, 100))],
):
gate._begin_lookup("apple")
assert "Review the values" in gate._status.get()
def test_multiple_candidates(self, gate: MealGate) -> None:
"""Several matches invite picking another."""
with patch.object(
_gatelock,
"lookup_candidates",
return_value=[
("a", _nutrition(95, 100)),
("b", _nutrition(120, 100)),
],
):
gate._begin_lookup("apple")
assert "pick another" in gate._status.get()
class TestRecord:
"""Logging a meal and advancing the slot walk."""
def test_demo_logs_without_slot(self, gate: MealGate) -> None:
"""A demo record banks the food but tags no real slot."""
gate._pending = [8]
with patch.object(_gatelock, "log_meal") as log:
gate._record("apple", _nutrition(95, 100))
assert log.call_args.args[2] is None
def test_last_slot_unlocks(self, gate: MealGate) -> None:
"""Recording the final pending slot triggers the unlock."""
gate._pending = [8]
with (
patch.object(_gatelock, "log_meal"),
patch.object(_gatelock, "remember_food"),
patch.object(gate, "_unlock") as unlock,
):
gate._record("apple", _nutrition(95, 100))
unlock.assert_called_once()
def test_more_slots_continue(self, gate: MealGate) -> None:
"""With slots remaining, the form clears and prompts the next."""
gate._pending = [8, 12]
with (
patch.object(_gatelock, "log_meal"),
patch.object(_gatelock, "remember_food"),
):
gate._record("apple", _nutrition(95, 100))
assert gate._pending == [12]
assert "next meal" in gate._status.get()
def test_unlock_schedules_close(self, gate: MealGate) -> None:
"""Unlock sets the closing status and schedules teardown."""
gate._unlock("logged X")
assert "unlocking" in gate._status.get()
class TestDashboard:
"""The running calorie/macro panel."""
def test_headline_with_budget(self, gate: MealGate) -> None:
"""A sealed budget shows consumed/target/remaining."""
seal_budget(2000)
gate._refresh_dashboard()
assert "left" in gate._cal_headline.get()
def test_headline_without_budget(self, gate: MealGate) -> None:
"""With no budget, only today's total is shown."""
gate._refresh_dashboard()
assert "kcal today" in gate._cal_headline.get()
def test_dashboard_lists_entries(self, gate: MealGate) -> None:
"""Logged entries appear in the detail panel."""
seal_budget(2000, weight_kg=80)
_gatelock.log_meal("apple", _nutrition(95, 100), 8)
gate._refresh_dashboard()
text = gate._dashboard.get()
assert "apple" in text
assert "protein" in text
def test_dashboard_empty(self, gate: MealGate) -> None:
"""With nothing logged, the panel says so."""
gate._refresh_dashboard()
assert "nothing logged yet" in gate._dashboard.get()
def test_slot_header_variants(self, gate: MealGate) -> None:
"""The header covers none / one / several pending slots."""
gate._pending = []
gate._refresh_slot_header()
assert "All meals logged" in gate._slot_header.get()
gate._pending = [8]
gate._refresh_slot_header()
assert "Log your" in gate._slot_header.get()
gate._pending = [8, 12]
gate._refresh_slot_header()
assert "remaining" in gate._slot_header.get()
def test_projection_with_budget(self, gate: MealGate) -> None:
"""The projection shows the after-this-item remaining when priced."""
seal_budget(2000)
gate._kcal_entry.insert(0, "300")
gate._refresh_projection()
assert "after this item" in gate._projection.get()
class TestWindowMechanics:
"""VT switching, grabbing, signals, and teardown."""
def test_disable_vt_no_tool(self, gate: MealGate) -> None:
"""A missing setxkbmap leaves VT switching enabled."""
with patch.object(_gatelock.shutil, "which", return_value=None):
gate._disable_vt_switching()
assert gate._vt_disabled is False
def test_disable_and_restore_vt(self, gate: MealGate) -> None:
"""With the tool present, VT switching toggles off then back on."""
with (
patch.object(_gatelock.shutil, "which", return_value="/x/setxkbmap"),
patch.object(_gatelock.subprocess, "run") as run,
):
gate._disable_vt_switching()
assert gate._vt_disabled is True
gate._restore_vt_switching()
assert gate._vt_disabled is False
assert run.call_count == 2
def test_restore_when_not_disabled(self, gate: MealGate) -> None:
"""Restoring when never disabled is a no-op."""
gate._vt_disabled = False
gate._restore_vt_switching()
def test_grab_success(self, gate: MealGate) -> None:
"""A successful grab focuses the first field."""
gate.root.grab_set_global = MagicMock()
gate._acquire_global_grab(attempt=1)
def test_grab_retries_on_conflict(self, gate: MealGate) -> None:
"""A held grab reschedules another attempt instead of giving up."""
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
gate.root.after = MagicMock()
gate._acquire_global_grab(attempt=_gatelock._GRAB_LOG_EVERY)
gate.root.after.assert_called_once()
def test_focus_first_field(self, gate: MealGate) -> None:
"""Focusing the first field is safe."""
gate._focus_first_field()
def test_keepalive_rearms(self, gate: MealGate) -> None:
"""The keepalive reschedules itself."""
gate.root.after = MagicMock()
gate._keepalive()
gate.root.after.assert_called_once()
def test_signal_restores_and_exits(self, gate: MealGate) -> None:
"""A termination signal restores VT switching and exits."""
with pytest.raises(SystemExit):
gate._on_signal(15, None)
def test_run_installs_and_loops(self, gate: MealGate) -> None:
"""run wires handlers, starts the loop, and restores on exit."""
gate.root.mainloop = MagicMock()
with (
patch.object(_gatelock.signal, "signal"),
patch.object(_gatelock.atexit, "register"),
):
gate.run()
gate.root.mainloop.assert_called_once()
def test_close(self, gate: MealGate) -> None:
"""Close restores VT switching and destroys the window."""
gate.root.destroy = MagicMock()
gate.close()
gate.root.destroy.assert_called_once()
def test_callback_error_status(self, gate: MealGate) -> None:
"""An unexpected callback error surfaces a recoverable message."""
gate._handle_callback_error()
assert "went wrong" in gate._status.get()
def test_restore_vt_without_tool(self, gate: MealGate) -> None:
"""Restoring when the tool has since vanished still clears the flag."""
gate._vt_disabled = True
with patch.object(_gatelock.shutil, "which", return_value=None):
gate._restore_vt_switching()
assert gate._vt_disabled is False
def test_grab_retry_without_log(self, gate: MealGate) -> None:
"""An early blocked attempt reschedules without logging."""
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
gate.root.after = MagicMock()
gate._acquire_global_grab(attempt=1)
gate.root.after.assert_called_once()
class TestBasisAndAmount:
"""Edge branches in the grams/items basis and amount maths."""
def test_basis_typed_value(self, gate: MealGate) -> None:
"""A typed per-value is honoured directly."""
gate._set_entry(gate._per_entry, "50")
assert gate._basis_grams() == 50
def test_basis_items_known_staple(self, gate: MealGate) -> None:
"""Items mode with a blank per falls back to the staple weight."""
gate._per_entry.delete(0)
gate._unit.set("items")
gate._set_desc("apple")
assert gate._basis_grams() == 182
def test_basis_items_unknown(self, gate: MealGate) -> None:
"""An unknown item uses the default piece weight."""
gate._per_entry.delete(0)
gate._unit.set("items")
gate._set_desc("mystery")
assert gate._basis_grams() == _gatelock.DEFAULT_ITEM_GRAMS
def test_basis_grams_default(self, gate: MealGate) -> None:
"""Grams mode with a blank per uses the per-100 g default."""
gate._per_entry.delete(0)
assert gate._basis_grams() == _gatelock._DEFAULT_PER_GRAMS
def test_eaten_grams_none(self, gate: MealGate) -> None:
"""No amount typed yields no eaten weight."""
assert gate._eaten_grams() is None
def test_eaten_grams_items(self, gate: MealGate) -> None:
"""Items mode multiplies the count by the per-item weight."""
gate._unit.set("items")
gate._set_desc("apple")
gate._set_entry(gate._per_entry, "182")
gate._set_entry(gate._amount_entry, "5")
assert gate._eaten_grams() == 5 * 182
def test_amount_change_refreshes(self, gate: MealGate) -> None:
"""Changing the amount recomputes the preview."""
gate._set_entry(gate._kcal_entry, "100")
gate._set_entry(gate._amount_entry, "200")
gate._on_amount_change(None)
assert gate._preview.get()
def test_projection_else_without_item(self, gate: MealGate) -> None:
"""With a budget but no priced item, no after-this-item is shown."""
seal_budget(2000)
gate._refresh_projection()
text = gate._projection.get()
assert "left" in text
assert "after this item" not in text
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
"""In grams mode the per-item weight is not touched on keyrelease."""
gate._unit.set("grams")
gate._set_desc("apple")
gate._on_desc_keyrelease(None)
def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
"""An unknown item in items mode leaves the per field unchanged."""
gate._unit.set("items")
gate._set_desc("zzzz")
gate._on_desc_keyrelease(None)
def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
"""A grams-mode pick does not overwrite an amount already typed."""
gate._set_entry(gate._amount_entry, "50")
gate._apply_reference(_nutrition(100, 100))
assert gate._amount_entry.get() == "50"
class TestDisplayReadiness:
"""The session-start display wait that absorbs the X auth-cookie race."""
def test_ready_when_root_connects(self) -> None:
"""A Tk root that builds and destroys cleanly means the display is up."""
fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError)
with patch.object(_gatelock, "tk", fake_tk):
assert _gatelock._display_is_ready() is True
fake_tk.Tk.return_value.destroy.assert_called_once()
def test_not_ready_on_tclerror(self) -> None:
"""A TclError from Tk() (no display / no cookie yet) means not ready."""
fake_tk = SimpleNamespace(
Tk=MagicMock(side_effect=_FakeTclError("no display")),
TclError=_FakeTclError,
)
with patch.object(_gatelock, "tk", fake_tk):
assert _gatelock._display_is_ready() is False
def test_wait_returns_immediately_when_ready(self) -> None:
"""A display ready on the first probe returns at once and never sleeps."""
sleep = MagicMock()
with patch.object(_gatelock, "_display_is_ready", return_value=True):
ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0))
assert ready is True
sleep.assert_not_called()
def test_wait_polls_then_succeeds(self) -> None:
"""Not-ready then ready sleeps once between probes, then unblocks."""
sleep = MagicMock()
monotonic = MagicMock(side_effect=[0.0, 0.0])
with patch.object(_gatelock, "_display_is_ready", side_effect=[False, True]):
assert wait_for_display(sleep=sleep, monotonic=monotonic) is True
sleep.assert_called_once()
def test_wait_times_out_and_defers(self) -> None:
"""A display still down at the deadline gives up so the next tick retries."""
sleep = MagicMock()
monotonic = MagicMock(side_effect=[0.0, 60.0])
with patch.object(_gatelock, "_display_is_ready", return_value=False):
assert wait_for_display(sleep=sleep, monotonic=monotonic) is False
sleep.assert_not_called()
class TestMealFlow:
"""Building and logging a multi-item composite meal."""
def test_meal_name_trimmed(self, gate: MealGate) -> None:
"""The meal name is read back trimmed."""
gate._meal_name_entry.insert(0, " dinner ")
assert gate._meal_name() == "dinner"
def test_summary_empty_with_no_items(self, gate: MealGate) -> None:
"""With no accumulated items the running summary is blank."""
gate._refresh_meal_summary()
assert gate._meal_summary.get() == ""
def test_summary_lists_items_and_total(self, gate: MealGate) -> None:
"""The summary shows the item names and the running calorie total."""
gate._meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
gate._refresh_meal_summary()
summary = gate._meal_summary.get()
assert "salad, chicken" in summary
assert "410 kcal" in summary
def test_add_item_requires_description(self, gate: MealGate) -> None:
"""Adding with no description prompts for one."""
gate._on_add_item()
assert "Type the item first" in gate._status.get()
def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before adding."""
gate._set_desc("salad")
gate._kcal_entry.insert(0, "abc")
gate._on_add_item()
assert "must be numbers" in gate._status.get()
def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None:
"""A blank calorie field looks the item up rather than adding."""
gate._set_desc("salad")
with patch.object(gate, "_begin_lookup") as lookup:
gate._on_add_item()
lookup.assert_called_once()
def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None:
"""A priced item that will not resolve prompts again (guard)."""
gate._set_desc("salad")
gate._kcal_entry.insert(0, "80")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_add_item()
assert "add the item" in gate._status.get()
def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None:
"""A valid item is appended, the form clears, the meal name is kept."""
gate._meal_name_entry.insert(0, "dinner")
gate._set_desc("salad")
gate._kcal_entry.insert(0, "80")
gate._on_add_item()
assert len(gate._meal_items) == 1
assert gate._meal_items[0].name == "salad"
assert gate._get_desc() == ""
assert gate._meal_name() == "dinner"
assert "Added salad" in gate._status.get()
def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None:
"""Submitting an empty form with items finalizes the meal."""
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
log_meal_.assert_called_once()
def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None:
"""A filled form plus existing items adds the form item, then logs."""
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._set_desc("rice")
gate._kcal_entry.insert(0, "260")
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
assert len(gate._meal_items) == 2
assert gate._meal_items[1].name == "rice"
log_meal_.assert_called_once()
def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None:
"""Logging a meal banks it under the typed name and advances the slot."""
gate._pending = [8, 12]
gate._meal_name_entry.insert(0, "dinner")
gate._meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
with (
patch.object(
_gatelock, "remember_meal", return_value=_nutrition(410, 320)
) as remember,
patch.object(_gatelock, "log_meal") as log,
):
gate._log_meal()
assert remember.call_args.args[0] == "dinner"
assert log.call_args.args[0] == "dinner"
assert gate._meal_items == []
assert gate._pending == [12]
def test_log_meal_uses_default_name(self, gate: MealGate) -> None:
"""A blank meal name falls back to the default."""
gate._pending = [8, 12]
gate._meal_items = [MealItem("soup", _nutrition(150, 300))]
with (
patch.object(
_gatelock, "remember_meal", return_value=_nutrition(150, 300)
) as remember,
patch.object(_gatelock, "log_meal"),
):
gate._log_meal()
assert remember.call_args.args[0] == _gatelock._DEFAULT_MEAL_NAME
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
"""A demo gate tags logs with no real slot."""
gate._pending = [8]
assert gate._slot_for_log() is None
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
"""A production gate tags logs with the current slot."""
gate.demo_mode = False
gate._pending = [12]
assert gate._slot_for_log() == 12
def test_clear_inputs_discards_meal(self, gate: MealGate) -> None:
"""Clearing between slots drops the in-progress meal and its name."""
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._meal_name_entry.insert(0, "dinner")
gate._meal_summary.set("something")
gate._clear_inputs()
assert gate._meal_items == []
assert gate._meal_name() == ""
assert gate._meal_summary.get() == ""
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
"""Finishing the final slot triggers unlock."""
gate._pending = [20]
with patch.object(gate, "_unlock") as unlock:
gate._finish_slot("done")
unlock.assert_called_once()