mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 13:23:11 +02:00
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:
parent
400f89b469
commit
84898c0272
@ -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,
|
||||||
|
|||||||
@ -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
217
diet_guard/_gatelock_core.py
Normal file
217
diet_guard/_gatelock_core.py
Normal 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}")
|
||||||
302
diet_guard/_gatelock_mealflow.py
Normal file
302
diet_guard/_gatelock_mealflow.py
Normal 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()
|
||||||
230
diet_guard/_gatelock_nutrition.py
Normal file
230
diet_guard/_gatelock_nutrition.py
Normal 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()
|
||||||
82
diet_guard/_gatelock_support.py
Normal file
82
diet_guard/_gatelock_support.py
Normal 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
458
diet_guard/_gatelock_ui.py
Normal 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,
|
||||||
|
)
|
||||||
171
diet_guard/_gatelock_window.py
Normal file
171
diet_guard/_gatelock_window.py
Normal 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()
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
425
diet_guard/tests/test_gatelock_mealflow.py
Normal file
425
diet_guard/tests/test_gatelock_mealflow.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user