feat: split oversized modules for 500-line limit, fix kasa coverage gap

Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the
usage_report.py/_usage_report_parsing.py pair into focused
sub-modules so every Python file is <= 500 lines, satisfying
test_file_length.py. Install python-kasa into .venv (declared in
requirements but missing after the 3.13->3.14 venv upgrade),
fixing 8 failing smart_plug tests and restoring 100% coverage.

Also includes prior in-progress work from the working tree: the
wake_alarm Progress/View/Hardware field-grouping refactor,
brother_printer query module + tests, diet_guard foodbank/state/cli
updates, new shared coerce/logging_setup helpers, morning_routine
orchestrator tweaks, dwm window-manager config, gaming scripts, and
misc maintenance/digital-wellbeing script updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-14 07:19:37 +02:00
parent 400f89b469
commit 84898c0272
14 changed files with 2227 additions and 1857 deletions

View File

@ -37,8 +37,8 @@ from python_pkg.diet_guard._gatelock import (
MealGate, MealGate,
acquire_gate_lock, acquire_gate_lock,
release_gate_lock, release_gate_lock,
wait_for_display,
) )
from python_pkg.diet_guard._gatelock_support import wait_for_display
from python_pkg.diet_guard._portions import ( from python_pkg.diet_guard._portions import (
DEFAULT_ITEM_GRAMS, DEFAULT_ITEM_GRAMS,
estimate_unit_grams, estimate_unit_grams,

View File

@ -25,6 +25,7 @@ from python_pkg.diet_guard._constants import FOOD_BANK_FILE
from python_pkg.diet_guard._estimator import Nutrition from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._fuzzy import match_score from python_pkg.diet_guard._fuzzy import match_score
from python_pkg.diet_guard._meal import MealItem, meal_total from python_pkg.diet_guard._meal import MealItem, meal_total
from python_pkg.shared.coerce import as_float
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
@ -119,24 +120,15 @@ def _record_to_nutrition(record: BankRecord) -> Nutrition:
The reconstructed Nutrition (source marked as the food bank). The reconstructed Nutrition (source marked as the food bank).
""" """
return Nutrition( return Nutrition(
kcal=_as_float(record.get("kcal")), kcal=as_float(record.get("kcal")),
protein_g=_as_float(record.get("protein_g")), protein_g=as_float(record.get("protein_g")),
carbs_g=_as_float(record.get("carbs_g")), carbs_g=as_float(record.get("carbs_g")),
fat_g=_as_float(record.get("fat_g")), fat_g=as_float(record.get("fat_g")),
grams=_as_float(record.get("grams")), grams=as_float(record.get("grams")),
source="food bank", source="food bank",
) )
def _as_float(value: object) -> float:
"""Coerce a stored field to float, defaulting to 0.0 (bools rejected)."""
if isinstance(value, bool):
return 0.0
if isinstance(value, (int, float)):
return float(value)
return 0.0
def remember_food(description: str, nutrition: Nutrition) -> None: def remember_food(description: str, nutrition: Nutrition) -> None:
"""Record (or refresh) a food in the bank, bumping its use count. """Record (or refresh) a food in the bank, bumping its use count.
@ -194,7 +186,7 @@ def _upsert(
return return
bank = _read_bank() bank = _read_bank()
previous = bank.get(key, {}) previous = bank.get(key, {})
count = _as_float(previous.get("count")) + 1 count = as_float(previous.get("count")) + 1
record: BankRecord = { record: BankRecord = {
"desc": description.strip(), "desc": description.strip(),
"kcal": nutrition.kcal, "kcal": nutrition.kcal,
@ -256,7 +248,7 @@ def search_foods(
score = match_score(normalized, key) score = match_score(normalized, key)
if score < _FUZZY_THRESHOLD: if score < _FUZZY_THRESHOLD:
continue continue
count = _as_float(record.get("count")) count = as_float(record.get("count"))
scored.append( scored.append(
(score, count, _display_name(record, key), _record_to_nutrition(record)), (score, count, _display_name(record, key), _record_to_nutrition(record)),
) )
@ -272,7 +264,7 @@ def _ranked_all(
"""Return all banked foods ranked by use count, most-logged first.""" """Return all banked foods ranked by use count, most-logged first."""
ranked = sorted( ranked = sorted(
bank.items(), bank.items(),
key=lambda item: _as_float(item[1].get("count")), key=lambda item: as_float(item[1].get("count")),
reverse=True, reverse=True,
) )
return [ return [

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,217 @@
"""Shared base class, root window, and state for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateCore`` holds the leaf widget/field helpers that every other
gatelock mixin (`_gatelock_window`, `_gatelock_nutrition`,
`_gatelock_mealflow`) derives from, plus the small dataclass (`_GateState`)
and Tk root subclass (`_GateRoot`) that :mod:`._gatelock` itself depends on.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.diet_guard._gatelock_ui import (
BASIS_PREFIX_GRAMS,
BASIS_PREFIX_ITEMS,
DEFAULT_PER_GRAMS,
UNIT_ITEMS,
GateVars,
GateWidgets,
)
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from python_pkg.diet_guard._slots import slot_label
if TYPE_CHECKING:
from collections.abc import Callable
from types import TracebackType
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._meal import MealItem
_logger = logging.getLogger(__name__)
def _safe_float(raw: str) -> float | None:
"""Return ``raw`` parsed as a float, or None if it is blank/non-numeric."""
if not raw:
return None
try:
return float(raw)
except ValueError:
return None
class _GateRoot(tk.Tk):
"""Tk root that routes callback errors to a handler instead of crashing.
Overriding ``report_callback_exception`` is the idiomatic, blind-except-free
way to guarantee that no exception raised inside a Tk callback escapes the
event loop -- essential while a global input grab is held.
"""
on_callback_error: Callable[[], None] | None = None
def report_callback_exception(
self,
exc: type[BaseException],
val: BaseException,
tb: TracebackType | None,
) -> None:
"""Log a callback error and notify the handler; never re-raise."""
_logger.error("gate callback error", exc_info=(exc, val, tb))
if self.on_callback_error is not None:
self.on_callback_error()
@dataclass
class _GateState:
"""Mutable logical state of the in-progress entry (no widget references).
``source`` is the provenance of the values in the reference fields
("manual", "food bank", "staple: apple", ...). It is a label only -- the
maths read the fields directly -- so there is no second copy of the numbers
to desync; it resets to "manual" the moment a macro is hand-edited.
``suggestions`` pairs each listed pick with its nutrition, and
``suggestion_mode`` says whether picking one overwrites the description
(bank entries are the user's own names) or only fills macros (OFF products).
``last_reference`` is the natural-basis nutrition of the food last picked
or looked up, kept so a grams<->items toggle can re-express it losslessly;
it is cleared the moment a macro is hand-edited. ``meal_items`` accumulates
the parts of a multi-item meal before they are logged as one summed entry.
"""
source: str = "manual"
suggestions: list[tuple[str, Nutrition]] = field(default_factory=list)
suggestion_mode: str = "bank"
last_reference: Nutrition | None = None
meal_items: list[MealItem] = field(default_factory=list)
class _GateCore:
"""Leaf widget/field helpers shared by every MealGate mixin.
Declares the attributes that
:class:`~python_pkg.diet_guard._gatelock.MealGate` sets up in ``__init__``
and ``_build`` so subclasses can reference them without tripping pylint's
no-member check.
"""
root: _GateRoot
demo_mode: bool
_vt_disabled: bool
_pending: list[int]
_state: _GateState
_vars: GateVars
_widgets: GateWidgets
close: Callable[[], None]
# -- description field ---------------------------------------------------
def _get_desc(self) -> str:
"""Return the description text, trimmed (a Text always trails a newline)."""
return self._widgets.desc_text.get("1.0", "end-1c").strip()
def _set_desc(self, value: str) -> None:
"""Replace the description box's contents with ``value``."""
self._widgets.desc_text.delete("1.0", tk.END)
if value:
self._widgets.desc_text.insert("1.0", value)
def _macro_entries(self) -> tuple[tk.Entry, ...]:
"""Return the four numeric entry widgets in (kcal, P, C, F) order."""
macros = self._widgets.macros
return (macros.kcal, macros.protein, macros.carbs, macros.fat)
# -- slot walk --------------------------------------------------------------
def _refresh_slot_header(self) -> None:
"""Update the header to prompt for the slot now being collected."""
total = len(self._pending)
if total == 0:
self._vars.slot_header.set("All meals logged.")
return
slot = self._pending[0]
position = "" if total == 1 else f" (1 of {total} remaining)"
self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}")
def _reset_per_default(self) -> None:
"""Set the "per" field to the basis default for the current unit."""
self._widgets.per_entry.delete(0, tk.END)
if self._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(self._get_desc())
self._widgets.per_entry.insert(
0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}"
)
else:
self._widgets.per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}")
def _relabel_basis(self) -> None:
"""Point the per-basis label at grams or per-item for the current unit."""
items = self._vars.unit.get() == UNIT_ITEMS
self._widgets.basis_prefix.config(
text=BASIS_PREFIX_ITEMS if items else BASIS_PREFIX_GRAMS,
)
# -- field helpers ------------------------------------------------------
def _basis_grams(self) -> float:
"""Return the grams the label macros describe (per 100 g or per item).
Honours an explicit "per" value when the user has typed one; otherwise
falls back to one piece's weight in items mode, or 100 g in grams mode.
"""
typed = _safe_float(self._widgets.per_entry.get().strip())
if typed is not None and typed > 0:
return typed
if self._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(self._get_desc())
return grams if grams is not None else DEFAULT_ITEM_GRAMS
return DEFAULT_PER_GRAMS
def _eaten_grams(self) -> float | None:
"""Return how many grams were eaten, or None if no amount is entered.
In grams mode the amount *is* the grams; in items mode it is multiplied
by one piece's weight (the "per" field), so "5 apples" becomes a weight.
"""
amount = _safe_float(self._widgets.amount_entry.get().strip())
if amount is None:
return None
if self._vars.unit.get() == UNIT_ITEMS:
return amount * self._basis_grams()
return amount
def _macro_values(self) -> tuple[float | None, ...] | None:
"""Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric."""
values: list[float | None] = []
for entry in self._macro_entries():
raw = entry.get().strip()
parsed = _safe_float(raw)
if raw and parsed is None:
return None
values.append(parsed)
return tuple(values)
def _set_entry(self, entry: tk.Entry, value: str) -> None:
"""Replace an entry's contents with ``value``."""
entry.delete(0, tk.END)
entry.insert(0, value)
def _fill_macro_fields(self, nutrition: Nutrition) -> None:
"""Write a nutrition's macros into the kcal/P/C/F fields."""
pairs = zip(
self._macro_entries(),
(
nutrition.kcal,
nutrition.protein_g,
nutrition.carbs_g,
nutrition.fat_g,
),
strict=True,
)
for entry, value in pairs:
self._set_entry(entry, f"{value:g}")

View File

@ -0,0 +1,302 @@
"""Submit/record/meal-building flow and dashboard for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateMealFlow`` extends
:class:`~python_pkg.diet_guard._gatelock_nutrition._GateNutrition` with the
submit/lookup/log flow for single foods and multi-item meals, the per-slot
input reset, and the running calorie/macro dashboard.
"""
from __future__ import annotations
import contextlib
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.diet_guard._budget import BudgetError, daily_budget, protein_target_g
from python_pkg.diet_guard._foodbank import remember_food, remember_meal
from python_pkg.diet_guard._gatelock_nutrition import _GateNutrition
from python_pkg.diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
from python_pkg.diet_guard._meal import MealItem, meal_total
from python_pkg.diet_guard._resolve import lookup_candidates
from python_pkg.diet_guard._slots import slot_label
from python_pkg.diet_guard._state import (
entry_kcal,
log_meal,
today_entries,
today_total_kcal,
today_total_macros,
)
if TYPE_CHECKING:
from python_pkg.diet_guard._estimator import Nutrition
# How long the "unlocking..." confirmation lingers before the window tears down.
_UNLOCK_DELAY_MS = 1200
# How many recent meals the dashboard lists.
_DASHBOARD_ROWS = 5
# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16.
_TIME_SLICE = slice(11, 16)
# Width a meal description is truncated to in the dashboard.
_DASH_DESC_WIDTH = 22
# Fallback name for a multi-item meal when the user leaves the name field blank.
_DEFAULT_MEAL_NAME = "meal"
class _GateMealFlow(_GateNutrition):
"""Submit/lookup/log flow for single foods and multi-item meals."""
# -- slot walk (meal-in-progress reset) ----------------------------------
def _clear_food_inputs(self) -> None:
"""Empty the food fields, picker, preview, and basis (keeps any meal)."""
self._set_desc("")
self._widgets.amount_entry.delete(0, tk.END)
self._vars.unit.set(UNIT_GRAMS)
self._relabel_basis()
self._reset_per_default()
for entry in self._macro_entries():
entry.delete(0, tk.END)
self._widgets.suggestion_box.delete(0, tk.END)
self._state.suggestions = []
self._state.source = "manual"
self._state.last_reference = None
self._vars.preview.set("")
self._refresh_projection()
def _clear_inputs(self) -> None:
"""Empty the food fields and discard any in-progress meal (new slot)."""
self._clear_food_inputs()
self._state.meal_items = []
self._widgets.meal_name_entry.delete(0, tk.END)
self._vars.meal_summary.set("")
# -- behaviour ------------------------------------------------------------
def _set_status(self, text: str, *, error: bool = False) -> None:
"""Update the status line, red for errors."""
self._vars.status.set(text)
self._widgets.status_label.config(fg=ERR if error else FG)
def _on_return(self, _event: tk.Event[tk.Misc]) -> None:
"""Handle the Enter key in any entry field."""
self._on_submit()
def _on_submit(self) -> None:
"""Validate, then look up, or log -- as a single food or a summed meal.
With a meal in progress, an empty form finalizes the accumulated items,
and a completed form adds itself as the meal's last item before logging.
With no meal in progress this is the original single-food path.
"""
description = self._get_desc()
if not description:
if self._state.meal_items:
self._log_meal()
return
self._set_status("Type what you ate first.", error=True)
self._widgets.desc_text.focus_set()
return
values = self._macro_values()
if values is None:
self._set_status("Macros must be numbers.", error=True)
self._widgets.macros.kcal.focus_set()
return
if values[0] is None:
self._begin_lookup(description)
return
nutrition = self._current_nutrition()
if nutrition is None:
self._set_status("Enter the calories, then submit.", error=True)
self._widgets.macros.kcal.focus_set()
return
if self._state.meal_items:
self._state.meal_items.append(MealItem(description, nutrition))
self._log_meal()
return
self._record(description, nutrition)
def _begin_lookup(self, description: str) -> None:
"""Step 1: look the food up, fill the label fields, offer alternatives.
Nothing is logged here -- the user must see and confirm the filled
values (a second submit) before they are recorded. The food is looked
up at its natural basis (per 100 g / serving); the amount eaten scales
it, so the lookup never bakes in a portion.
"""
self._set_status("looking up…")
self.root.update_idletasks()
candidates = lookup_candidates(description)
if not candidates:
self._set_status(
"Couldn't look that up. Enter the calories yourself, then submit.",
error=True,
)
self._widgets.macros.kcal.focus_set()
return
self._show_candidates(candidates)
self._apply_reference(candidates[0][1])
source = candidates[0][1].source
tail = (
"Review, or pick another below, then submit to log."
if len(candidates) > 1
else "Review the values, then submit to log."
)
self._set_status(f"Filled from {source}. {tail}")
def _record(self, description: str, nutrition: Nutrition) -> None:
"""Log and bank a single food for the current slot, then advance."""
log_meal(description, nutrition, self._slot_for_log())
remember_food(description, nutrition)
self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})")
def _meal_name(self) -> str:
"""Return the trimmed meal name the user typed (empty if none)."""
return self._widgets.meal_name_entry.get().strip()
def _refresh_meal_summary(self) -> None:
"""Update the running "meal so far" line from the accumulated items."""
if not self._state.meal_items:
self._vars.meal_summary.set("")
return
total = meal_total(self._state.meal_items)
names = ", ".join(item.name for item in self._state.meal_items)
self._vars.meal_summary.set(
f"Meal so far ({len(self._state.meal_items)}): {names}"
f"{total.kcal:g} kcal · P{total.protein_g:g} "
f"C{total.carbs_g:g} F{total.fat_g:g}",
)
def _on_add_item(self) -> None:
"""Add the current form as one component of a multi-part meal.
Requires a name and resolved calories (a blank calorie field triggers a
lookup first, exactly like submitting). On success the item is appended
to the meal-in-progress, the running total updates, and the food fields
clear for the next item while the meal name is kept.
"""
description = self._get_desc()
if not description:
self._set_status("Type the item first, then add it.", error=True)
self._widgets.desc_text.focus_set()
return
values = self._macro_values()
if values is None:
self._set_status("Macros must be numbers.", error=True)
self._widgets.macros.kcal.focus_set()
return
if values[0] is None:
self._begin_lookup(description)
return
nutrition = self._current_nutrition()
if nutrition is None:
self._set_status("Enter the calories, then add the item.", error=True)
self._widgets.macros.kcal.focus_set()
return
self._state.meal_items.append(MealItem(description, nutrition))
self._refresh_meal_summary()
self._clear_food_inputs()
self._set_status(f"Added {description}. Add another, or Log & Continue.")
self._widgets.desc_text.focus_set()
def _slot_for_log(self) -> int | None:
"""Return the slot to tag a log with -- None in demo (satisfies no slot).
A synthetic demo slot must never satisfy a real checkpoint, so demo logs
are slot-less: they still bank the food and update the dashboard, but do
not silently stop the production gate from firing.
"""
return None if self.demo_mode else self._pending[0]
def _log_meal(self) -> None:
"""Log the accumulated multi-item meal for the current slot and advance.
Each component and the summed composite are banked (see
:func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is
satisfied by the summed total under the meal's name.
"""
name = self._meal_name() or _DEFAULT_MEAL_NAME
count = len(self._state.meal_items)
total = remember_meal(name, list(self._state.meal_items))
log_meal(name, total, self._slot_for_log())
self._state.meal_items = []
self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)")
def _finish_slot(self, summary: str) -> None:
"""Advance past the current slot after something was logged for it.
Args:
summary: A short description of what was logged (calories/source, or
the meal name and item count), shown in the confirmation line.
"""
slot = self._pending[0]
self._pending.pop(0)
self._refresh_dashboard()
logged = f"Logged {slot_label(slot)}: {summary}"
if not self._pending:
self._unlock(logged)
return
self._clear_inputs()
self._refresh_slot_header()
self._set_status(f"{logged} — next meal, please.")
self._widgets.desc_text.focus_set()
def _unlock(self, logged: str) -> None:
"""Confirm the final log and tear the window down.
Teardown is scheduled *before* the budget is looked up, so a broken
budget seal (which raises) can never re-trap the user at unlock time.
"""
self._set_status(f"{logged} — all meals logged, unlocking…")
self.root.after(_UNLOCK_DELAY_MS, self.close)
# -- dashboard --------------------------------------------------------------
def _refresh_dashboard(self) -> None:
"""Recompute the prominent calorie headline and the detail panel."""
self._vars.cal_headline.set(self._cal_headline_text())
self._vars.dashboard.set(self._dashboard_text())
def _cal_headline_text(self) -> str:
"""Return the big calories-today line: consumed, target, and remaining."""
consumed = today_total_kcal()
try:
budget = daily_budget()
except (BudgetError, OSError):
return f"{consumed:g} kcal today"
return (
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left"
)
def _dashboard_text(self) -> str:
"""Build the detail panel: recent meals, then macros and protein."""
lines = ["── Today ───────────────────────────────"]
entries = today_entries()
if entries:
for entry in entries[-_DASHBOARD_ROWS:]:
clock = str(entry.get("time", ""))[_TIME_SLICE]
desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH]
lines.append(
f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} "
f"{entry_kcal(entry):>5.0f} kcal",
)
else:
lines.append(" (nothing logged yet today)")
protein, carbs, fat = today_total_macros()
lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g")
target = protein_target_g()
if target is not None:
left = round(target - protein, 1)
lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)")
return "\n".join(lines)
def _handle_callback_error(self) -> None:
"""Surface an unexpected callback error without dropping the grab."""
self._set_status(
"Something went wrong. Enter the calories, then submit again.",
error=True,
)
with contextlib.suppress(tk.TclError):
self._widgets.macros.kcal.focus_set()

View File

@ -0,0 +1,230 @@
"""Reference-to-total nutrition model and food lookup for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateNutrition`` extends
:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the
"reference -> total" nutrition maths -- the label macros describe one basis
(per 100 g or per item), and how much was eaten scales that reference into
what gets logged -- plus the live preview/projection and the
autocomplete/lookup flow that fills the reference fields from banked foods,
staples, or Open Food Facts.
"""
from __future__ import annotations
import tkinter as tk
from python_pkg.diet_guard._budget import BudgetError, daily_budget
from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition
from python_pkg.diet_guard._gatelock_core import _GateCore
from python_pkg.diet_guard._gatelock_ui import (
DEFAULT_PER_GRAMS,
SUGGESTION_ROWS,
UNIT_ITEMS,
)
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from python_pkg.diet_guard._resolve import suggest_foods
from python_pkg.diet_guard._state import today_total_kcal
def _format_preview(nutrition: Nutrition) -> str:
"""Render the one-line "this is what will be logged" preview."""
portion = f" · {nutrition.grams:g}g" if nutrition.grams else ""
return (
f"{nutrition.kcal:g} kcal · "
f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
f"{portion} · {nutrition.source}"
)
class _GateNutrition(_GateCore):
"""Reference->total nutrition maths, live preview, and food lookup."""
# -- the reference -> total model --------------------------------------
def _reference_nutrition(self) -> Nutrition | None:
"""Return the label values as a Nutrition, or None if calories are blank.
This is the *reference* (macros for one basis -- per 100 g, or per item),
not the total: how much was eaten scales it in :meth:`_current_nutrition`.
"""
values = self._macro_values()
if values is None or values[0] is None:
return None
return Nutrition(
kcal=values[0],
protein_g=values[1] or 0.0,
carbs_g=values[2] or 0.0,
fat_g=values[3] or 0.0,
grams=self._basis_grams(),
source=self._state.source,
)
def _current_nutrition(self) -> Nutrition | None:
"""Return exactly what would be logged now, or None if not yet resolvable.
The label reference scaled to the amount eaten. With no amount yet, the
reference itself stands in (one basis portion), so the preview is never
empty just because an amount has not been typed.
"""
reference = self._reference_nutrition()
if reference is None:
return None
eaten = self._eaten_grams()
return scale_nutrition(reference, eaten) if eaten is not None else reference
def _refresh_preview(self) -> None:
"""Recompute the preview line and the live calorie projection."""
nutrition = self._current_nutrition()
self._vars.preview.set(
_format_preview(nutrition) if nutrition is not None else ""
)
self._refresh_projection()
def _refresh_projection(self) -> None:
"""Show consumed / budget / remaining, and what is left after this item.
This answers, as the calories are typed, the four numbers the user asked
to see together: how much is already eaten today, the day's goal, how
much is left now, and how much would be left *after* logging the food
currently in the form. With no budget sealed it degrades to the running
total plus this item's calories, so it is always informative.
"""
consumed = today_total_kcal()
nutrition = self._current_nutrition()
this_kcal = nutrition.kcal if nutrition is not None else 0.0
try:
budget = daily_budget()
except (BudgetError, OSError):
tail = f" · this item {this_kcal:g} kcal" if this_kcal else ""
self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}")
return
left = round(budget - consumed, 1)
base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left"
if this_kcal:
after = round(budget - consumed - this_kcal, 1)
self._vars.projection.set(f"{base} → after this item: {after:g} left")
else:
self._vars.projection.set(base)
# -- autocomplete / lookup ---------------------------------------------
def _on_desc_keyrelease(self, _event: tk.Event[tk.Misc]) -> None:
"""Refresh suggestions; in items mode, show the piece's weight."""
query = self._get_desc()
self._populate_suggestions(query)
# In items mode, surface a recognised piece's weight as it is typed, so
# "apple" visibly becomes "≈ 182 g" rather than a hidden assumption.
if self._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(query)
if grams is not None:
self._set_entry(self._widgets.per_entry, f"{grams:g}")
self._refresh_preview()
def _populate_suggestions(self, query: str) -> None:
"""Fill the picker with banked foods and matching staples for ``query``."""
self._state.suggestion_mode = "bank"
self._state.suggestions = suggest_foods(query, limit=SUGGESTION_ROWS)
self._widgets.suggestion_box.delete(0, tk.END)
for name, nutrition in self._state.suggestions:
self._widgets.suggestion_box.insert(
tk.END, f"{name} ({nutrition.kcal:g} kcal)"
)
def _show_candidates(self, candidates: list[tuple[str, Nutrition]]) -> None:
"""Fill the picker with looked-up alternatives to choose from."""
self._state.suggestion_mode = "candidates"
self._state.suggestions = candidates
self._widgets.suggestion_box.delete(0, tk.END)
for label, nutrition in candidates:
self._widgets.suggestion_box.insert(
tk.END,
f"{label} ({nutrition.kcal:g} kcal · {nutrition.grams:g}g)",
)
def _on_suggestion_select(self, _event: tk.Event[tk.Misc]) -> None:
"""Fill the form from the picked suggestion."""
selection = self._widgets.suggestion_box.curselection()
if not selection:
return
index = selection[0]
if index >= len(self._state.suggestions):
return
name, nutrition = self._state.suggestions[index]
# Banked/staple entries carry a name, so adopt it; OFF products only
# supply macros and must not overwrite what the user typed.
if self._state.suggestion_mode == "bank":
self._apply_reference(nutrition, name=name)
else:
self._apply_reference(nutrition)
def _apply_reference(
self, nutrition: Nutrition, *, name: str | None = None
) -> None:
"""Adopt ``nutrition`` as the reference and mirror it into the fields.
In grams mode the food's own weight is the "per" basis and its macros
fill the fields directly. In items mode the per-100 g reference is
converted to a single piece (its weight shown in "per"), so the macro
fields read *per item*. The amount eaten does the scaling either way.
"""
self._state.source = nutrition.source
self._state.last_reference = nutrition
if name is not None:
self._set_desc(name)
if self._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(self._get_desc())
unit = grams if grams is not None else DEFAULT_ITEM_GRAMS
self._set_entry(self._widgets.per_entry, f"{unit:g}")
self._fill_macro_fields(scale_nutrition(nutrition, unit))
else:
basis = nutrition.grams or DEFAULT_PER_GRAMS
self._set_entry(self._widgets.per_entry, f"{basis:g}")
self._fill_macro_fields(nutrition)
# Default the eaten amount to one reference portion so a pick is
# immediately loggable (grams mode only -- items need a count).
if not self._widgets.amount_entry.get().strip() and nutrition.grams:
self._set_entry(self._widgets.amount_entry, f"{nutrition.grams:g}")
self._refresh_preview()
# -- live recompute -----------------------------------------------------
def _on_amount_change(self, _event: tk.Event[tk.Misc]) -> None:
"""Recompute the preview when the amount or basis changes.
Crucially this does *not* rewrite the macro fields: those hold the label
reference, and only the previewed/logged total reflects the new amount.
"""
self._refresh_preview()
def _on_unit_change(self, _value: str) -> None:
"""Switch grams<->items, re-expressing the picked food in the new basis.
The macro fields mean different things in each mode (per 100 g / per
portion vs per item). When a food was picked or looked up, its stored
reference is re-applied so toggling converts the values back and forth
losslessly. A hand-typed (manual) entry has no clean reference to
convert, so its fields are cleared to be re-entered in the new basis
rather than silently reinterpreted.
"""
self._relabel_basis()
self._widgets.amount_entry.delete(0, tk.END)
if self._state.last_reference is not None:
self._apply_reference(self._state.last_reference)
return
for entry in self._macro_entries():
entry.delete(0, tk.END)
self._reset_per_default()
self._state.source = "manual"
self._refresh_preview()
def _on_macro_edit(self, _event: tk.Event[tk.Misc]) -> None:
"""A hand-edited macro becomes the manual reference from here on.
Editing a macro by hand invalidates the picked food's stored reference:
the fields no longer match it, so a later unit toggle must not snap them
back to it.
"""
self._state.source = "manual"
self._state.last_reference = None
self._refresh_preview()

View File

@ -0,0 +1,82 @@
"""Session-start display-readiness probing for the diet_guard gate.
Standalone infrastructure split out of :mod:`._gatelock` to keep that module
focused on the gate window itself. The gate's systemd timer fires the instant
the user systemd instance starts (``Persistent=true`` catch-up of the slot
missed while the PC was off), which on a fresh login can BEAT the display
manager writing ``~/.Xauthority`` and the X server becoming reachable. That
race -- not the slot logic -- silently dropped the session-start launch: the Tk
root raised ``TclError`` ("couldn't connect to display") and the oneshot
service died. So before building the window the launcher polls here until the
display is connectable; on timeout the gate exits cleanly and the next timer
tick retries, instead of crashing.
"""
from __future__ import annotations
import logging
import time
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_DISPLAY_WAIT_TIMEOUT_S = 60.0
_DISPLAY_POLL_INTERVAL_S = 1.0
def _display_is_ready() -> bool:
"""Return True if a Tk root can connect to the X display right now.
Builds and immediately destroys a throwaway, unmapped root -- the cheapest
way to ask "is DISPLAY reachable and authorized?" without opening a visible
window. A missing display or a not-yet-written X auth cookie raises
``tk.TclError``, which is reported here as not-ready.
"""
try:
probe = tk.Tk()
except tk.TclError:
return False
probe.destroy()
return True
def wait_for_display(
*,
timeout_s: float = _DISPLAY_WAIT_TIMEOUT_S,
interval_s: float = _DISPLAY_POLL_INTERVAL_S,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> bool:
"""Block until the X display is connectable, or ``timeout_s`` elapses.
Absorbs the session-start race in which the gate's timer fires before the
display manager has finished writing the X auth cookie (see the module
note). ``sleep`` and ``monotonic`` are injectable so the wait is tested
without real time passing.
Args:
timeout_s: Total seconds to keep retrying before giving up.
interval_s: Seconds to wait between connection probes.
sleep: Sleep function (injected in tests).
monotonic: Monotonic clock (injected in tests).
Returns:
True as soon as a probe connects; False if the deadline passes with the
display still unreachable (the caller should defer to the next tick).
"""
deadline = monotonic() + timeout_s
while True:
if _display_is_ready():
return True
if monotonic() >= deadline:
_logger.warning(
"X display unreachable after %.0fs (session still settling?); "
"deferring the gate to the next timer tick",
timeout_s,
)
return False
sleep(interval_s)

458
diet_guard/_gatelock_ui.py Normal file
View File

@ -0,0 +1,458 @@
"""Widget construction for the diet_guard meal gate.
This module owns the *view* half of the gate: the palette, the data bundles
that hold the live string variables and the interactive widgets, and the pure
functions that lay the window out. It deliberately knows nothing about slot
logic, nutrition maths, or logging -- the controller (:mod:`._gatelock`) keeps
all of that. Splitting the construction out keeps each file focused and within
a readable size; the controller imports :func:`build_layout` and wires events
to the widgets it gets back.
The build functions take only public parameters (the root, the string-variable
bundle, and a small callbacks bundle) and return the populated widget bundle.
Event bindings that map to controller methods are left to the controller, so no
controller internals ever cross the module boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic).
BG = "#1a1a1a"
FG = "#e0e0e0"
_ACCENT = "#00ff88"
ERR = "#ff6666"
_FIELD_BG = "#2a2a2a"
_MUTED = "#9a9a9a"
# Number of food-bank / staple / OFF suggestions shown in the picker list.
SUGGESTION_ROWS = 5
# Grams a label's macros are assumed to describe when the "per" field is blank.
DEFAULT_PER_GRAMS = 100.0
# Unit-selector choices for how a portion is measured.
UNIT_GRAMS = "grams"
UNIT_ITEMS = "items"
# Per-basis label prefixes for the two measuring modes.
BASIS_PREFIX_GRAMS = "Nutrition as on the label — per"
BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈"
@dataclass
class _MacroEntries:
"""The four macro entry widgets, in (kcal, protein, carbs, fat) order."""
kcal: tk.Entry
protein: tk.Entry
carbs: tk.Entry
fat: tk.Entry
@dataclass
class GateVars:
"""Tk string variables bound to the gate's live, auto-updating fields."""
status: tk.StringVar
slot_header: tk.StringVar
preview: tk.StringVar
projection: tk.StringVar
cal_headline: tk.StringVar
dashboard: tk.StringVar
meal_summary: tk.StringVar
unit: tk.StringVar
@dataclass
class GateWidgets:
"""Interactive widgets the controller reads back after the UI is built."""
desc_text: tk.Text
amount_entry: tk.Entry
per_entry: tk.Entry
basis_prefix: tk.Label
macros: _MacroEntries
suggestion_box: tk.Listbox
meal_name_entry: tk.Entry
status_label: tk.Label
@dataclass
class GateCallbacks:
"""Construction-time commands the widgets fire (not key/event bindings).
These are the callbacks that must be supplied when a widget is created --
option-menu and button commands. Per-keystroke event bindings are wired by
the controller after the layout is built, so they are not carried here.
"""
on_unit_change: Callable[[str], None]
on_submit: Callable[[], None]
on_close: Callable[[], None]
on_add_item: Callable[[], None]
def make_vars(root: tk.Misc) -> GateVars:
"""Create the gate's string variables, all mastered to ``root``."""
return GateVars(
status=tk.StringVar(master=root, value=""),
slot_header=tk.StringVar(master=root, value=""),
preview=tk.StringVar(master=root, value=""),
projection=tk.StringVar(master=root, value=""),
cal_headline=tk.StringVar(master=root, value=""),
dashboard=tk.StringVar(master=root, value=""),
meal_summary=tk.StringVar(master=root, value=""),
unit=tk.StringVar(master=root, value=UNIT_GRAMS),
)
def is_numeric_or_blank(proposed: str) -> bool:
"""Validate-on-key predicate: allow only a blank field or a number."""
if proposed == "":
return True
try:
float(proposed)
except ValueError:
return False
return True
def _numeric_entry(root: tk.Misc, parent: tk.Frame, *, width: int) -> tk.Entry:
"""Return an entry that only accepts a number or a blank string."""
vcmd = (root.register(is_numeric_or_blank), "%P")
return tk.Entry(
parent,
font=("Arial", 15),
width=width,
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
justify="center",
validate="key",
validatecommand=vcmd,
)
def _macro_cell(root: tk.Misc, row: tk.Frame, label: str) -> tk.Entry:
"""Pack one small labelled numeric entry into the macro row."""
cell = tk.Frame(row, bg=BG)
cell.pack(side="left", padx=6)
tk.Label(cell, text=label, font=("Arial", 11), bg=BG, fg=FG).pack()
entry = _numeric_entry(root, cell, width=7)
entry.pack(ipady=3)
return entry
def _build_desc(parent: tk.Frame) -> tk.Text:
"""Build and return the multi-line "what did you eat?" description box.
A multi-line ``Text`` (not an ``Entry``) so a long restaurant description
wraps onto a second line and stays fully visible, instead of scrolling off
the right edge where the end can no longer be read.
"""
tk.Label(
parent,
text="What did you eat?",
font=("Arial", 12),
bg=BG,
fg=FG,
).pack()
text = tk.Text(
parent,
font=("Arial", 15),
width=64,
height=2,
wrap="word",
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
highlightthickness=1,
highlightbackground=_MUTED,
)
text.pack(pady=(2, 6))
return text
def _build_suggestion_box(parent: tk.Frame) -> tk.Listbox:
"""Build the food-bank / staple / OFF picker list and return it."""
box = tk.Listbox(
parent,
font=("Arial", 12),
width=52,
height=SUGGESTION_ROWS,
bg=_FIELD_BG,
fg=FG,
selectbackground=_ACCENT,
selectforeground="#003322",
activestyle="none",
highlightthickness=0,
)
box.pack(pady=(0, 8))
return box
def _build_amount_row(
root: tk.Misc,
parent: tk.Frame,
unit_var: tk.StringVar,
on_unit_change: Callable[[str], None],
) -> tk.Entry:
"""Build the "how much did you eat?" amount + unit row; return the entry."""
tk.Label(
parent,
text="How much did you eat?",
font=("Arial", 12),
bg=BG,
fg=FG,
).pack()
row = tk.Frame(parent, bg=BG)
row.pack(pady=(2, 6))
amount_entry = _numeric_entry(root, row, width=10)
amount_entry.pack(side="left", ipady=3)
unit_menu = tk.OptionMenu(
row,
unit_var,
UNIT_GRAMS,
UNIT_ITEMS,
command=on_unit_change,
)
unit_menu.configure(
font=("Arial", 12),
bg=_FIELD_BG,
fg=FG,
activebackground=_ACCENT,
highlightthickness=0,
)
unit_menu.pack(side="left", padx=(8, 0))
return amount_entry
def _build_macro_section(
root: tk.Misc,
parent: tk.Frame,
) -> tuple[tk.Label, tk.Entry, _MacroEntries]:
"""Build the per-basis field and macro row.
Returns the basis-prefix label, the "per" entry, and the four macro entries,
for the caller to store in the widget bundle.
"""
basis = tk.Frame(parent, bg=BG)
basis.pack()
basis_prefix = tk.Label(
basis,
text=BASIS_PREFIX_GRAMS,
font=("Arial", 12),
bg=BG,
fg=FG,
)
basis_prefix.pack(side="left")
per_entry = _numeric_entry(root, basis, width=5)
per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}")
per_entry.pack(side="left", padx=4, ipady=2)
tk.Label(
basis,
text="g (leave calories blank to look it up):",
font=("Arial", 12),
bg=BG,
fg=FG,
).pack(side="left")
row = tk.Frame(parent, bg=BG)
row.pack(pady=(2, 6))
macros = _MacroEntries(
kcal=_macro_cell(root, row, "kcal"),
protein=_macro_cell(root, row, "P"),
carbs=_macro_cell(root, row, "C"),
fat=_macro_cell(root, row, "F"),
)
return basis_prefix, per_entry, macros
def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
"""Build the running "how am I doing today" panel.
The calorie line is large and prominent (the number the user steers by); the
meal list and macros sit beneath it in a smaller monospace block.
"""
tk.Label(
parent,
textvariable=vars_.cal_headline,
font=("Arial", 22, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(12, 0))
tk.Label(
parent,
textvariable=vars_.dashboard,
font=("Courier", 11),
bg=BG,
fg=_MUTED,
justify="left",
anchor="w",
wraplength=900,
).pack(pady=(2, 0))
def _build_meal_controls(
parent: tk.Frame,
vars_: GateVars,
on_add_item: Callable[[], None],
) -> tk.Entry:
"""Build the optional multi-item meal row; return the meal-name entry.
Logging stays one-tap for a single food; these controls only matter when a
meal has several separately-macroed parts (a dinner of salad + chicken +
rice). "Add item" banks the part onto the meal-in-progress and clears the
form for the next one; "Log & Continue" then logs the summed meal.
"""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(2, 2))
tk.Label(
row,
text="Meal name (optional):",
font=("Arial", 11),
bg=BG,
fg=FG,
).pack(side="left")
meal_name_entry = tk.Entry(
row,
font=("Arial", 13),
width=18,
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
)
meal_name_entry.pack(side="left", padx=(6, 8), ipady=2)
tk.Button(
row,
text="+ Add item",
font=("Arial", 12, "bold"),
bg=_FIELD_BG,
fg=_ACCENT,
activebackground="#333333",
cursor="hand2",
command=on_add_item,
).pack(side="left")
tk.Label(
parent,
textvariable=vars_.meal_summary,
font=("Arial", 11),
bg=BG,
fg=_MUTED,
wraplength=900,
justify="center",
).pack(pady=(0, 2))
return meal_name_entry
def build_layout(
root: tk.Misc,
vars_: GateVars,
callbacks: GateCallbacks,
*,
demo_mode: bool,
) -> GateWidgets:
"""Lay out the whole gate UI and return the widgets the controller drives.
The controller calls this once (after configuring the window) and is then
responsible for binding per-keystroke events to the returned widgets.
"""
frame = tk.Frame(root, bg=BG)
frame.place(relx=0.5, rely=0.5, anchor="center")
tk.Label(
frame,
text="🍽 Diet Gate",
font=("Arial", 30, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(0, 4))
tk.Label(
frame,
textvariable=vars_.slot_header,
font=("Arial", 16, "bold"),
bg=BG,
fg=FG,
wraplength=900,
justify="center",
).pack(pady=(0, 10))
desc_text = _build_desc(frame)
suggestion_box = _build_suggestion_box(frame)
amount_entry = _build_amount_row(
root,
frame,
vars_.unit,
callbacks.on_unit_change,
)
basis_prefix, per_entry, macros = _build_macro_section(root, frame)
tk.Label(
frame,
textvariable=vars_.projection,
font=("Arial", 13, "bold"),
bg=BG,
fg=FG,
wraplength=900,
justify="center",
).pack(pady=(2, 2))
tk.Label(
frame,
textvariable=vars_.preview,
font=("Arial", 14, "bold"),
bg=BG,
fg=_ACCENT,
wraplength=900,
justify="center",
).pack(pady=(2, 6))
meal_name_entry = _build_meal_controls(frame, vars_, callbacks.on_add_item)
tk.Button(
frame,
text="Log & Continue",
font=("Arial", 15, "bold"),
bg=_ACCENT,
fg="#003322",
activebackground="#00cc66",
cursor="hand2",
command=callbacks.on_submit,
).pack(pady=(4, 6))
status_label = tk.Label(
frame,
textvariable=vars_.status,
font=("Arial", 12),
bg=BG,
fg=FG,
wraplength=900,
justify="center",
)
status_label.pack()
_build_dashboard(frame, vars_)
if demo_mode:
tk.Button(
root,
text="✕ Close Demo",
font=("Arial", 12),
bg="#ff4444",
fg="white",
command=callbacks.on_close,
cursor="hand2",
).place(x=10, y=10)
return GateWidgets(
desc_text=desc_text,
amount_entry=amount_entry,
per_entry=per_entry,
basis_prefix=basis_prefix,
macros=macros,
suggestion_box=suggestion_box,
meal_name_entry=meal_name_entry,
status_label=status_label,
)

View File

@ -0,0 +1,171 @@
"""Window mechanics and process lifecycle for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateWindow`` extends
:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the
screen-locker-style window setup (fullscreen, VT-switch disable, global input
grab with retry) and the signal/atexit lifecycle that guarantees VT switching
is restored on every exit path.
"""
from __future__ import annotations
import atexit
import contextlib
import logging
import shutil
import signal
import subprocess
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.diet_guard._gatelock_core import _GateCore
from python_pkg.diet_guard._gatelock_ui import BG
if TYPE_CHECKING:
from types import FrameType
_logger = logging.getLogger(__name__)
# Periodic no-op so the grabbed, event-starved loop keeps handing control back
# to Python, letting SIGTERM/SIGINT be serviced promptly.
_KEEPALIVE_MS = 250
# A global input grab fails while another X client already holds one -- most
# often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A
# single attempt then falls back to a *local* grab, which on an override-redirect
# window the WM refuses to focus means no keystroke ever reaches the field -- the
# "can't type anything" lock-trap. So the grab is retried for the window's whole
# life: the gate waits out the game and captures input the instant it is freed.
_GRAB_RETRY_MS = 200
# How often (in attempts) to log that the grab is still blocked, so the journal
# shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms.
_GRAB_LOG_EVERY = 25
class _GateWindow(_GateCore):
"""Fullscreen window setup, input grab, and exit-path lifecycle."""
# -- window mechanics (reused screen-locker pattern) --------------------
def _setup_window(self) -> None:
"""Configure the lock window.
Demo mode stays WM-managed so the window manager still grants it
keyboard focus -- and you can always close it -- making a usable, safe
sandbox. Only the real lock uses ``overrideredirect``, where the tiling
WM refuses focus and input is instead forced in by a global grab.
"""
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
self.root.geometry(f"{screen_w}x{screen_h}+0+0")
self.root.attributes(topmost=True)
self.root.configure(bg=BG, cursor="arrow")
if self.demo_mode:
self.root.attributes(fullscreen=True)
else:
self.root.overrideredirect(boolean=True)
self.root.attributes(fullscreen=True)
self._disable_vt_switching()
def _disable_vt_switching(self) -> None:
"""Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort)."""
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is None:
_logger.warning("setxkbmap not found; VT switching stays enabled")
return
subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False)
self._vt_disabled = True
def _restore_vt_switching(self) -> None:
"""Re-enable VT switching; idempotent and safe to call on any exit."""
if not self._vt_disabled:
return
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is not None:
subprocess.run([setxkbmap, "-option", ""], check=False)
self._vt_disabled = False
def _grab_input(self) -> None:
"""Force input to the window, then focus the first field.
Demo mode relies on normal WM focus (no grab), keeping the window an
escapable sandbox. The real lock forces *all* input here with a global
grab -- the only mechanism that reaches an overrideredirect window the
tiling WM will not focus. The grab is acquired with retries because it
commonly fails on the first attempt while the window is still mapping.
"""
self.root.update_idletasks()
self.root.focus_force()
if not self.demo_mode:
self._acquire_global_grab(attempt=1)
self.root.after(100, self._focus_first_field)
def _acquire_global_grab(self, *, attempt: int) -> None:
"""Acquire the global input grab, retrying until it succeeds.
A successful global grab is the only way keystrokes reach the
override-redirect window the WM will not focus. When another client
(typically a fullscreen game) holds the grab, the attempt is rescheduled
indefinitely rather than conceding to an unusable local grab, so the gate
waits the other application out and captures input the moment it frees
the grab. On success, focus is forced onto the description field so the
first keystroke lands there.
Args:
attempt: 1-based attempt counter, used only to throttle the log.
"""
try:
self.root.grab_set_global()
except tk.TclError:
if attempt % _GRAB_LOG_EVERY == 0:
_logger.warning(
"global grab still blocked after %d attempts (another app -- "
"e.g. a fullscreen game -- holds it); waiting for it to free",
attempt,
)
self.root.after(
_GRAB_RETRY_MS,
lambda: self._acquire_global_grab(attempt=attempt + 1),
)
return
with contextlib.suppress(tk.TclError):
self.root.focus_force()
self._focus_first_field()
def _focus_first_field(self) -> None:
"""Put keyboard focus on the description entry once it is mapped."""
with contextlib.suppress(tk.TclError):
self._widgets.desc_text.focus_force()
# -- lifecycle ------------------------------------------------------------
def _install_signal_handlers(self) -> None:
"""Ensure VT switching is restored on crash or kill, not just close."""
atexit.register(self._restore_vt_switching)
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(ValueError):
signal.signal(sig, self._on_signal)
def _on_signal(self, _signum: int, _frame: FrameType | None) -> None:
"""Restore the keyboard escape, then exit, on SIGTERM/SIGINT."""
self._restore_vt_switching()
raise SystemExit(0)
def _keepalive(self) -> None:
"""Re-arm a periodic no-op so pending signals get serviced promptly."""
self.root.after(_KEEPALIVE_MS, self._keepalive)
def close(self) -> None:
"""Restore VT switching and destroy the window (no process exit)."""
self._restore_vt_switching()
with contextlib.suppress(tk.TclError):
self.root.destroy()
def run(self) -> None:
"""Run the Tk loop, restoring VT switching on every exit path."""
self._install_signal_handlers()
self._keepalive()
try:
self.root.mainloop()
finally:
self._restore_vt_switching()

View File

@ -17,6 +17,7 @@ from typing import TYPE_CHECKING
from python_pkg.diet_guard._budget import daily_budget from python_pkg.diet_guard._budget import daily_budget
from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
from python_pkg.shared.coerce import as_float
from python_pkg.shared.log_integrity import ( from python_pkg.shared.log_integrity import (
compute_entry_hmac, compute_entry_hmac,
verify_entry_hmac, verify_entry_hmac,
@ -58,12 +59,7 @@ def _entry_float(entry: dict[str, object], key: str) -> float:
Returns: Returns:
The field as a float, or 0.0 when absent or not a real number. The field as a float, or 0.0 when absent or not a real number.
""" """
value = entry.get(key) return as_float(entry.get(key))
if isinstance(value, bool):
return 0.0
if isinstance(value, (int, float)):
return float(value)
return 0.0
def entry_kcal(entry: dict[str, object]) -> float: def entry_kcal(entry: dict[str, object]) -> float:

View File

@ -7,15 +7,33 @@ Two safety nets run for every test:
* ``_block_real_tk`` swaps ``tk`` and the ``_GateRoot`` window class inside * ``_block_real_tk`` swaps ``tk`` and the ``_GateRoot`` window class inside
``_gatelock`` for mocks, so no test can open a real fullscreen window or grab ``_gatelock`` for mocks, so no test can open a real fullscreen window or grab
the keyboard even if it forgets to. the keyboard even if it forgets to.
The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...)
build a demo :class:`~python_pkg.diet_guard._gatelock.MealGate` whose widgets
are functional in-memory stand-ins, shared by ``test_gatelock.py`` and
``test_gatelock_mealflow.py``.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import ExitStack
from types import SimpleNamespace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from python_pkg.diet_guard import (
_gatelock,
_gatelock_core,
_gatelock_mealflow,
_gatelock_nutrition,
_gatelock_ui,
_gatelock_window,
)
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._gatelock import MealGate
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
@ -67,3 +85,161 @@ def _hmac_key(tmp_path: Path) -> Iterator[None]:
key.write_bytes(b"diet-guard-test-key-0123456789ab") key.write_bytes(b"diet-guard-test-key-0123456789ab")
with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key): with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key):
yield yield
# --------------------------------------------------------------------------
# Gate fixture and its functional tk fakes
# --------------------------------------------------------------------------
#
# A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
# real, catchable ``TclError``) replaces the blanket MagicMock above 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.
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,
)
# Every mixin module the gate window is built from imports ``tkinter``
# independently; all of them must see the fake so ``tk.TclError`` etc. are the
# catchable ``_FakeTclError`` everywhere a test raises it.
_GATE_TK_MODULES = (
_gatelock,
_gatelock_core,
_gatelock_window,
_gatelock_nutrition,
_gatelock_mealflow,
_gatelock_ui,
)
@pytest.fixture
def gate() -> Iterator[MealGate]:
"""Build a demo gate whose widgets are functional fakes."""
with ExitStack() as stack:
for module in _GATE_TK_MODULES:
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
yield MealGate(demo_mode=True)
def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition:
"""A simple reference nutrition for driving the gate form."""
return Nutrition(kcal, 10, 20, 5, grams, "food bank")

View File

@ -35,22 +35,6 @@ def _write_raw(bank: object) -> None:
_foodbank.FOOD_BANK_FILE.write_text(json.dumps(bank), encoding="utf-8") _foodbank.FOOD_BANK_FILE.write_text(json.dumps(bank), encoding="utf-8")
class TestAsFloat:
"""Field coercion with the bool rejection."""
def test_bool_is_zero(self) -> None:
"""A bool is not a real count/macro."""
assert _foodbank._as_float(value=True) == 0.0
def test_number_passes(self) -> None:
"""Ints and floats pass through."""
assert _foodbank._as_float(7) == 7.0
def test_other_is_zero(self) -> None:
"""A non-numeric value defaults to 0.0."""
assert _foodbank._as_float("x") == 0.0
class TestRememberAndLookup: class TestRememberAndLookup:
"""Round-tripping foods through the bank.""" """Round-tripping foods through the bank."""

View File

@ -1,9 +1,9 @@
"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window. """Tests for _gatelock.py — the fullscreen log-to-unlock gate window.
A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a Window mechanics, construction, and the shared module-level helpers. The
real ``TclError``) replaces the conftest's blanket MagicMock for the duration of nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the
each gate test, so the window's *logic* runs for real against in-memory widgets functional fake ``tk`` widgets and the ``gate`` fixture live in
without ever opening a window or grabbing the keyboard. ``conftest.py`` and are shared by both files.
""" """
from __future__ import annotations from __future__ import annotations
@ -13,159 +13,32 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from python_pkg.diet_guard import _gatelock from python_pkg.diet_guard import (
_gatelock,
_gatelock_support,
_gatelock_ui,
_gatelock_window,
)
from python_pkg.diet_guard._budget import seal_budget from python_pkg.diet_guard._budget import seal_budget
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._gatelock import ( from python_pkg.diet_guard._gatelock import (
MealGate, MealGate,
_format_preview,
_pending_slots, _pending_slots,
_safe_float,
acquire_gate_lock, acquire_gate_lock,
release_gate_lock, release_gate_lock,
wait_for_display,
) )
from python_pkg.diet_guard._meal import MealItem from python_pkg.diet_guard._gatelock_core import _safe_float
from python_pkg.diet_guard._gatelock_nutrition import _format_preview
from python_pkg.diet_guard._gatelock_support import wait_for_display
from python_pkg.diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
from python_pkg.diet_guard._gatelock_window import _GRAB_LOG_EVERY
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS
from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition
# Captured before any autouse fixture patches the module attribute, so the real # Captured before any autouse fixture patches the module attribute, so the real
# class (not the conftest MagicMock) is available for its callback-error test. # class (not the conftest MagicMock) is available for its callback-error test.
_REAL_GATE_ROOT = _gatelock._GateRoot _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 # Module-level helpers
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -268,13 +141,13 @@ class TestConstruction:
def test_demo_builds(self, gate: MealGate) -> None: def test_demo_builds(self, gate: MealGate) -> None:
"""A demo gate constructs with a pending slot and grams basis.""" """A demo gate constructs with a pending slot and grams basis."""
assert gate.demo_mode is True assert gate.demo_mode is True
assert gate._unit.get() == "grams" assert gate._vars.unit.get() == "grams"
def test_production_builds(self) -> None: def test_production_builds(self) -> None:
"""A production gate disables VT switching and grabs input.""" """A production gate disables VT switching and grabs input."""
with ( with (
patch.object(_gatelock, "tk", _FAKE_TK), patch.object(_gatelock, "tk", _FAKE_TK),
patch.object(_gatelock.shutil, "which", return_value=None), patch.object(_gatelock_window.shutil, "which", return_value=None),
): ):
gate = MealGate(demo_mode=False) gate = MealGate(demo_mode=False)
assert gate.demo_mode is False assert gate.demo_mode is False
@ -288,11 +161,11 @@ class TestConstruction:
class TestFormBasics: class TestFormBasics:
"""Field helpers and the numeric validator.""" """Field helpers and the numeric validator."""
def test_numeric_validator(self, gate: MealGate) -> None: def test_numeric_validator(self) -> None:
"""Blank and numbers are allowed; words are not.""" """Blank and numbers are allowed; words are not."""
assert gate._is_numeric_or_blank("") assert _gatelock_ui.is_numeric_or_blank("")
assert gate._is_numeric_or_blank("12.5") assert _gatelock_ui.is_numeric_or_blank("12.5")
assert not gate._is_numeric_or_blank("abc") assert not _gatelock_ui.is_numeric_or_blank("abc")
def test_desc_get_set(self, gate: MealGate) -> None: def test_desc_get_set(self, gate: MealGate) -> None:
"""The description round-trips through its helpers, trimmed.""" """The description round-trips through its helpers, trimmed."""
@ -308,264 +181,81 @@ class TestFormBasics:
def test_macro_values_non_numeric(self, gate: MealGate) -> None: def test_macro_values_non_numeric(self, gate: MealGate) -> None:
"""A non-numeric macro field makes the whole read None.""" """A non-numeric macro field makes the whole read None."""
gate._kcal_entry.insert(0, "abc") gate._widgets.macros.kcal.insert(0, "abc")
assert gate._macro_values() is None assert gate._macro_values() is None
class TestReferenceModel: class TestBasisAndAmount:
"""The reference -> total nutrition computation.""" """Edge branches in the grams/items basis and amount maths."""
def test_reference_none_without_calories(self, gate: MealGate) -> None: def test_basis_typed_value(self, gate: MealGate) -> None:
"""No calories typed means no reference yet.""" """A typed per-value is honoured directly."""
assert gate._reference_nutrition() is None gate._set_entry(gate._widgets.per_entry, "50")
assert gate._basis_grams() == 50
def test_current_is_reference_without_amount(self, gate: MealGate) -> None: def test_basis_items_known_staple(self, gate: MealGate) -> None:
"""With calories but no amount, the reference stands in as the total.""" """Items mode with a blank per falls back to the staple weight."""
gate._kcal_entry.insert(0, "200") gate._widgets.per_entry.delete(0)
current = gate._current_nutrition() gate._vars.unit.set("items")
assert current is not None gate._set_desc("apple")
assert current.kcal == 200 assert gate._basis_grams() == 182
def test_current_scales_with_amount(self, gate: MealGate) -> None: def test_basis_items_unknown(self, gate: MealGate) -> None:
"""Grams eaten scale the per-100 g reference into the total.""" """An unknown item uses the default piece weight."""
gate._kcal_entry.insert(0, "200") gate._widgets.per_entry.delete(0)
gate._amount_entry.insert(0, "200") gate._vars.unit.set("items")
current = gate._current_nutrition() gate._set_desc("mystery")
assert current is not None assert gate._basis_grams() == DEFAULT_ITEM_GRAMS
assert current.kcal == 400
def test_basis_grams_default(self, gate: MealGate) -> None:
"""Grams mode with a blank per uses the per-100 g default."""
gate._widgets.per_entry.delete(0)
assert gate._basis_grams() == DEFAULT_PER_GRAMS
class TestSuggestions: def test_eaten_grams_none(self, gate: MealGate) -> None:
"""Autocomplete population and selection.""" """No amount typed yields no eaten weight."""
assert gate._eaten_grams() is None
def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None: def test_eaten_grams_items(self, gate: MealGate) -> None:
"""In items mode, typing a staple fills the per-item weight.""" """Items mode multiplies the count by the per-item weight."""
gate._unit.set("items") gate._vars.unit.set("items")
gate._set_desc("apple")
gate._set_entry(gate._widgets.per_entry, "182")
gate._set_entry(gate._widgets.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._widgets.macros.kcal, "100")
gate._set_entry(gate._widgets.amount_entry, "200")
gate._on_amount_change(None)
assert gate._vars.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._vars.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._vars.unit.set("grams")
gate._set_desc("apple") gate._set_desc("apple")
gate._on_desc_keyrelease(None) gate._on_desc_keyrelease(None)
assert gate._per_entry.get() == "182"
def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None: def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
"""Picking a banked suggestion adopts its name and macros.""" """An unknown item in items mode leaves the per field unchanged."""
gate._suggestions = [("apple pie", _nutrition(300, 120))] gate._vars.unit.set("items")
gate._suggestion_mode = "bank" gate._set_desc("zzzz")
gate._suggestion_box.selection_set(0) gate._on_desc_keyrelease(None)
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: def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
"""An OFF candidate fills macros but not the typed description.""" """A grams-mode pick does not overwrite an amount already typed."""
gate._set_desc("my dish") gate._set_entry(gate._widgets.amount_entry, "50")
gate._suggestions = [("openfoodfacts: X", _nutrition(250, 100))] gate._apply_reference(_nutrition(100, 100))
gate._suggestion_mode = "candidates" assert gate._widgets.amount_entry.get() == "50"
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: class TestWindowMechanics:
@ -573,15 +263,15 @@ class TestWindowMechanics:
def test_disable_vt_no_tool(self, gate: MealGate) -> None: def test_disable_vt_no_tool(self, gate: MealGate) -> None:
"""A missing setxkbmap leaves VT switching enabled.""" """A missing setxkbmap leaves VT switching enabled."""
with patch.object(_gatelock.shutil, "which", return_value=None): with patch.object(_gatelock_window.shutil, "which", return_value=None):
gate._disable_vt_switching() gate._disable_vt_switching()
assert gate._vt_disabled is False assert gate._vt_disabled is False
def test_disable_and_restore_vt(self, gate: MealGate) -> None: def test_disable_and_restore_vt(self, gate: MealGate) -> None:
"""With the tool present, VT switching toggles off then back on.""" """With the tool present, VT switching toggles off then back on."""
with ( with (
patch.object(_gatelock.shutil, "which", return_value="/x/setxkbmap"), patch.object(_gatelock_window.shutil, "which", return_value="/x/setxkbmap"),
patch.object(_gatelock.subprocess, "run") as run, patch.object(_gatelock_window.subprocess, "run") as run,
): ):
gate._disable_vt_switching() gate._disable_vt_switching()
assert gate._vt_disabled is True assert gate._vt_disabled is True
@ -603,7 +293,7 @@ class TestWindowMechanics:
"""A held grab reschedules another attempt instead of giving up.""" """A held grab reschedules another attempt instead of giving up."""
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError) gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
gate.root.after = MagicMock() gate.root.after = MagicMock()
gate._acquire_global_grab(attempt=_gatelock._GRAB_LOG_EVERY) gate._acquire_global_grab(attempt=_GRAB_LOG_EVERY)
gate.root.after.assert_called_once() gate.root.after.assert_called_once()
def test_focus_first_field(self, gate: MealGate) -> None: def test_focus_first_field(self, gate: MealGate) -> None:
@ -625,8 +315,8 @@ class TestWindowMechanics:
"""run wires handlers, starts the loop, and restores on exit.""" """run wires handlers, starts the loop, and restores on exit."""
gate.root.mainloop = MagicMock() gate.root.mainloop = MagicMock()
with ( with (
patch.object(_gatelock.signal, "signal"), patch.object(_gatelock_window.signal, "signal"),
patch.object(_gatelock.atexit, "register"), patch.object(_gatelock_window.atexit, "register"),
): ):
gate.run() gate.run()
gate.root.mainloop.assert_called_once() gate.root.mainloop.assert_called_once()
@ -640,12 +330,12 @@ class TestWindowMechanics:
def test_callback_error_status(self, gate: MealGate) -> None: def test_callback_error_status(self, gate: MealGate) -> None:
"""An unexpected callback error surfaces a recoverable message.""" """An unexpected callback error surfaces a recoverable message."""
gate._handle_callback_error() gate._handle_callback_error()
assert "went wrong" in gate._status.get() assert "went wrong" in gate._vars.status.get()
def test_restore_vt_without_tool(self, gate: MealGate) -> None: def test_restore_vt_without_tool(self, gate: MealGate) -> None:
"""Restoring when the tool has since vanished still clears the flag.""" """Restoring when the tool has since vanished still clears the flag."""
gate._vt_disabled = True gate._vt_disabled = True
with patch.object(_gatelock.shutil, "which", return_value=None): with patch.object(_gatelock_window.shutil, "which", return_value=None):
gate._restore_vt_switching() gate._restore_vt_switching()
assert gate._vt_disabled is False assert gate._vt_disabled is False
@ -657,87 +347,14 @@ class TestWindowMechanics:
gate.root.after.assert_called_once() 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: class TestDisplayReadiness:
"""The session-start display wait that absorbs the X auth-cookie race.""" """The session-start display wait that absorbs the X auth-cookie race."""
def test_ready_when_root_connects(self) -> None: def test_ready_when_root_connects(self) -> None:
"""A Tk root that builds and destroys cleanly means the display is up.""" """A Tk root that builds and destroys cleanly means the display is up."""
fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError) fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError)
with patch.object(_gatelock, "tk", fake_tk): with patch.object(_gatelock_support, "tk", fake_tk):
assert _gatelock._display_is_ready() is True assert _gatelock_support._display_is_ready() is True
fake_tk.Tk.return_value.destroy.assert_called_once() fake_tk.Tk.return_value.destroy.assert_called_once()
def test_not_ready_on_tclerror(self) -> None: def test_not_ready_on_tclerror(self) -> None:
@ -746,13 +363,13 @@ class TestDisplayReadiness:
Tk=MagicMock(side_effect=_FakeTclError("no display")), Tk=MagicMock(side_effect=_FakeTclError("no display")),
TclError=_FakeTclError, TclError=_FakeTclError,
) )
with patch.object(_gatelock, "tk", fake_tk): with patch.object(_gatelock_support, "tk", fake_tk):
assert _gatelock._display_is_ready() is False assert _gatelock_support._display_is_ready() is False
def test_wait_returns_immediately_when_ready(self) -> None: def test_wait_returns_immediately_when_ready(self) -> None:
"""A display ready on the first probe returns at once and never sleeps.""" """A display ready on the first probe returns at once and never sleeps."""
sleep = MagicMock() sleep = MagicMock()
with patch.object(_gatelock, "_display_is_ready", return_value=True): with patch.object(_gatelock_support, "_display_is_ready", return_value=True):
ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0)) ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0))
assert ready is True assert ready is True
sleep.assert_not_called() sleep.assert_not_called()
@ -761,7 +378,9 @@ class TestDisplayReadiness:
"""Not-ready then ready sleeps once between probes, then unblocks.""" """Not-ready then ready sleeps once between probes, then unblocks."""
sleep = MagicMock() sleep = MagicMock()
monotonic = MagicMock(side_effect=[0.0, 0.0]) monotonic = MagicMock(side_effect=[0.0, 0.0])
with patch.object(_gatelock, "_display_is_ready", side_effect=[False, True]): with patch.object(
_gatelock_support, "_display_is_ready", side_effect=[False, True]
):
assert wait_for_display(sleep=sleep, monotonic=monotonic) is True assert wait_for_display(sleep=sleep, monotonic=monotonic) is True
sleep.assert_called_once() sleep.assert_called_once()
@ -769,149 +388,6 @@ class TestDisplayReadiness:
"""A display still down at the deadline gives up so the next tick retries.""" """A display still down at the deadline gives up so the next tick retries."""
sleep = MagicMock() sleep = MagicMock()
monotonic = MagicMock(side_effect=[0.0, 60.0]) monotonic = MagicMock(side_effect=[0.0, 60.0])
with patch.object(_gatelock, "_display_is_ready", return_value=False): with patch.object(_gatelock_support, "_display_is_ready", return_value=False):
assert wait_for_display(sleep=sleep, monotonic=monotonic) is False assert wait_for_display(sleep=sleep, monotonic=monotonic) is False
sleep.assert_not_called() 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()

View File

@ -0,0 +1,425 @@
"""Tests for the nutrition model, lookup, and meal-building flow of MealGate.
Covers :mod:`._gatelock_nutrition` (reference -> total maths, suggestions,
unit toggling) and :mod:`._gatelock_mealflow` (submit/lookup/record, the
dashboard, and multi-item meals). The functional fake ``tk`` widgets and the
``gate`` fixture live in ``conftest.py`` and are shared with
:mod:`test_gatelock`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from python_pkg.diet_guard import _gatelock_mealflow
from python_pkg.diet_guard._budget import seal_budget
from python_pkg.diet_guard._meal import MealItem
from python_pkg.diet_guard._state import log_meal
from python_pkg.diet_guard.tests.conftest import _nutrition
if TYPE_CHECKING:
from python_pkg.diet_guard._gatelock import MealGate
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._widgets.macros.kcal.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._widgets.macros.kcal.insert(0, "200")
gate._widgets.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._vars.unit.set("items")
gate._set_desc("apple")
gate._on_desc_keyrelease(None)
assert gate._widgets.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._state.suggestions = [("apple pie", _nutrition(300, 120))]
gate._state.suggestion_mode = "bank"
gate._widgets.suggestion_box.selection_set(0)
gate._on_suggestion_select(None)
assert gate._get_desc() == "apple pie"
assert gate._widgets.macros.kcal.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._state.suggestions = [("openfoodfacts: X", _nutrition(250, 100))]
gate._state.suggestion_mode = "candidates"
gate._widgets.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._state.suggestions = []
gate._widgets.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._vars.unit.set("items")
gate._on_unit_change("items")
per_item = gate._widgets.macros.kcal.get()
gate._vars.unit.set("grams")
gate._on_unit_change("grams")
assert gate._widgets.macros.kcal.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._widgets.macros.kcal.insert(0, "123")
gate._state.last_reference = None
gate._vars.unit.set("items")
gate._on_unit_change("items")
assert gate._widgets.macros.kcal.get() == ""
def test_macro_edit_drops_reference(self, gate: MealGate) -> None:
"""Hand-editing a macro invalidates the stored reference."""
gate._state.last_reference = _nutrition()
gate._on_macro_edit(None)
assert gate._state.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._vars.status.get()
def test_non_numeric_macros(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before logging."""
gate._set_desc("apple")
gate._widgets.macros.kcal.insert(0, "abc")
gate._on_submit()
assert "must be numbers" in gate._vars.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._widgets.macros.kcal.insert(0, "200")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_submit()
assert "Enter the calories" in gate._vars.status.get()
def test_valid_submit_records(self, gate: MealGate) -> None:
"""A described, priced meal is recorded."""
gate._set_desc("apple")
gate._widgets.macros.kcal.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_mealflow, "lookup_candidates", return_value=[]):
gate._begin_lookup("nonsense")
assert "Couldn't look that up" in gate._vars.status.get()
def test_single_candidate(self, gate: MealGate) -> None:
"""A single match fills the fields and invites review."""
with patch.object(
_gatelock_mealflow,
"lookup_candidates",
return_value=[("apple", _nutrition(95, 100))],
):
gate._begin_lookup("apple")
assert "Review the values" in gate._vars.status.get()
def test_multiple_candidates(self, gate: MealGate) -> None:
"""Several matches invite picking another."""
with patch.object(
_gatelock_mealflow,
"lookup_candidates",
return_value=[
("a", _nutrition(95, 100)),
("b", _nutrition(120, 100)),
],
):
gate._begin_lookup("apple")
assert "pick another" in gate._vars.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_mealflow, "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_mealflow, "log_meal"),
patch.object(_gatelock_mealflow, "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_mealflow, "log_meal"),
patch.object(_gatelock_mealflow, "remember_food"),
):
gate._record("apple", _nutrition(95, 100))
assert gate._pending == [12]
assert "next meal" in gate._vars.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._vars.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._vars.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._vars.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)
log_meal("apple", _nutrition(95, 100), 8)
gate._refresh_dashboard()
text = gate._vars.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._vars.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._vars.slot_header.get()
gate._pending = [8]
gate._refresh_slot_header()
assert "Log your" in gate._vars.slot_header.get()
gate._pending = [8, 12]
gate._refresh_slot_header()
assert "remaining" in gate._vars.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._widgets.macros.kcal.insert(0, "300")
gate._refresh_projection()
assert "after this item" in gate._vars.projection.get()
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._widgets.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._vars.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._state.meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
gate._refresh_meal_summary()
summary = gate._vars.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._vars.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._widgets.macros.kcal.insert(0, "abc")
gate._on_add_item()
assert "must be numbers" in gate._vars.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._widgets.macros.kcal.insert(0, "80")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_add_item()
assert "add the item" in gate._vars.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._widgets.meal_name_entry.insert(0, "dinner")
gate._set_desc("salad")
gate._widgets.macros.kcal.insert(0, "80")
gate._on_add_item()
assert len(gate._state.meal_items) == 1
assert gate._state.meal_items[0].name == "salad"
assert gate._get_desc() == ""
assert gate._meal_name() == "dinner"
assert "Added salad" in gate._vars.status.get()
def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None:
"""Submitting an empty form with items finalizes the meal."""
gate._state.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._state.meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._set_desc("rice")
gate._widgets.macros.kcal.insert(0, "260")
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
assert len(gate._state.meal_items) == 2
assert gate._state.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._widgets.meal_name_entry.insert(0, "dinner")
gate._state.meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
with (
patch.object(
_gatelock_mealflow,
"remember_meal",
return_value=_nutrition(410, 320),
) as remember,
patch.object(_gatelock_mealflow, "log_meal") as log,
):
gate._log_meal()
assert remember.call_args.args[0] == "dinner"
assert log.call_args.args[0] == "dinner"
assert gate._state.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._state.meal_items = [MealItem("soup", _nutrition(150, 300))]
with (
patch.object(
_gatelock_mealflow,
"remember_meal",
return_value=_nutrition(150, 300),
) as remember,
patch.object(_gatelock_mealflow, "log_meal"),
):
gate._log_meal()
assert remember.call_args.args[0] == _gatelock_mealflow._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._state.meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._widgets.meal_name_entry.insert(0, "dinner")
gate._vars.meal_summary.set("something")
gate._clear_inputs()
assert gate._state.meal_items == []
assert gate._meal_name() == ""
assert gate._vars.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()