mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 21:23:16 +02:00
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>
918 lines
34 KiB
Python
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()
|