diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 0ecf846..096d111 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -37,8 +37,8 @@ from python_pkg.diet_guard._gatelock import ( MealGate, acquire_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 ( DEFAULT_ITEM_GRAMS, estimate_unit_grams, diff --git a/diet_guard/_foodbank.py b/diet_guard/_foodbank.py index 8ea3d22..1cc85aa 100644 --- a/diet_guard/_foodbank.py +++ b/diet_guard/_foodbank.py @@ -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._fuzzy import match_score from python_pkg.diet_guard._meal import MealItem, meal_total +from python_pkg.shared.coerce import as_float if TYPE_CHECKING: 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). """ return Nutrition( - kcal=_as_float(record.get("kcal")), - protein_g=_as_float(record.get("protein_g")), - carbs_g=_as_float(record.get("carbs_g")), - fat_g=_as_float(record.get("fat_g")), - grams=_as_float(record.get("grams")), + kcal=as_float(record.get("kcal")), + protein_g=as_float(record.get("protein_g")), + carbs_g=as_float(record.get("carbs_g")), + fat_g=as_float(record.get("fat_g")), + grams=as_float(record.get("grams")), 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: """Record (or refresh) a food in the bank, bumping its use count. @@ -194,7 +186,7 @@ def _upsert( return bank = _read_bank() previous = bank.get(key, {}) - count = _as_float(previous.get("count")) + 1 + count = as_float(previous.get("count")) + 1 record: BankRecord = { "desc": description.strip(), "kcal": nutrition.kcal, @@ -256,7 +248,7 @@ def search_foods( score = match_score(normalized, key) if score < _FUZZY_THRESHOLD: continue - count = _as_float(record.get("count")) + count = as_float(record.get("count")) scored.append( (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.""" ranked = sorted( bank.items(), - key=lambda item: _as_float(item[1].get("count")), + key=lambda item: as_float(item[1].get("count")), reverse=True, ) return [ diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index d40ba0c..323ab6b 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -30,157 +30,38 @@ source, and offers alternatives. A running dashboard makes the day's calories prominent, with macros and the protein target beneath. The unlock condition is *logging*, never *estimating correctly*: a manual calorie value always works offline, so a dead OFF endpoint can never trap you behind the lock. + +Building ``MealGate`` spans several sibling modules to keep each under the +repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf +widget/field helpers, root window, and state (``_GateCore``, ``_GateRoot``, +``_GateState``); :mod:`._gatelock_window` provides the fullscreen window setup, +input grab, and exit-path lifecycle (``_GateWindow``); +:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and +food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the +submit/log flow and dashboard (``_GateMealFlow``). ``MealGate`` wires these +mixins together and owns construction, layout, and event binding. """ from __future__ import annotations -import atexit import contextlib import fcntl -import logging -import shutil -import signal -import subprocess import sys -import time 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._constants import GATE_LOCK_FILE -from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition -from python_pkg.diet_guard._foodbank import remember_food, remember_meal from python_pkg.diet_guard._gate import due_slots -from python_pkg.diet_guard._meal import MealItem, meal_total -from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams -from python_pkg.diet_guard._resolve import lookup_candidates, suggest_foods -from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label -from python_pkg.diet_guard._state import ( - entry_kcal, - log_meal, - now_local, - today_entries, - today_total_kcal, - today_total_macros, -) +from python_pkg.diet_guard._gatelock_core import _GateRoot, _GateState +from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow +from python_pkg.diet_guard._gatelock_ui import GateCallbacks, build_layout, make_vars +from python_pkg.diet_guard._gatelock_window import _GateWindow +from python_pkg.diet_guard._slots import current_slot, day_slots +from python_pkg.diet_guard._state import now_local if TYPE_CHECKING: - from collections.abc import Callable - from types import FrameType, TracebackType from typing import TextIO -_logger = logging.getLogger(__name__) - -# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). -_BG = "#1a1a1a" -_FG = "#e0e0e0" -_ACCENT = "#00ff88" -_ERR = "#ff6666" -_FIELD_BG = "#2a2a2a" -_MUTED = "#9a9a9a" -# How long the "unlocking..." confirmation lingers before the window tears down. -_UNLOCK_DELAY_MS = 1200 -# 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 -# 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 ≈" -# 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" -# -- display readiness (session-start race) --------------------------------- -# 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 -- is what silently -# dropped the session-start launch: _GateRoot() raised TclError ("couldn't -# connect to display") and the oneshot service died. So before building the -# window we poll the display until it is connectable; on timeout the gate exits -# cleanly and the next timer tick retries, instead of crashing. -_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) - def _assert_not_under_pytest() -> None: """Raise if a real Tk gate is being built inside a pytest run. @@ -194,26 +75,6 @@ def _assert_not_under_pytest() -> None: raise RuntimeError(msg) -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 - - -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}" - ) - - def acquire_gate_lock() -> TextIO | None: """Acquire the gate's single-instance ``flock``. @@ -260,29 +121,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]: return [] -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() - - -class MealGate: +class MealGate(_GateWindow, _GateMealFlow): """A fullscreen lock that dismisses only once every missing slot is logged.""" def __init__(self, *, demo_mode: bool = True) -> None: @@ -297,1038 +136,60 @@ class MealGate: self.demo_mode = demo_mode self._vt_disabled = False self._pending = _pending_slots(demo_mode=demo_mode) - # Provenance of the values currently in the reference fields ("manual", - # "food bank", "staple: apple", ...). Label only -- it never affects the - # maths, which read the fields directly -- so there is no second copy of - # the numbers to desync. Set when a food is picked/looked up; reset to - # "manual" the moment the user hand-edits a macro. - self._source = "manual" - # Suggestions currently listed, paired with their nutrition; the mode - # says whether picking one should also overwrite the description (bank - # entries are the user's own names) or only fill macros (OFF products). - self._suggestions: list[tuple[str, Nutrition]] = [] - self._suggestion_mode = "bank" - # The natural-basis nutrition of the food last picked or looked up (per - # 100 g for staples, per logged portion for banked foods). Kept so a - # grams<->items toggle can re-express it losslessly in the new basis; - # set to None the moment the user hand-edits a macro (then there is no - # clean reference to convert and the fields are cleared instead). - self._last_reference: Nutrition | None = None - # Components accumulated for a multi-item meal (salad + chicken + rice) - # before it is logged as one summed entry; empty for a single food. - self._meal_items: list[MealItem] = [] + # All mutable logical state (provenance, suggestions, meal-in-progress) + # lives in one bundle; see _GateState for the per-field rationale. + self._state = _GateState() self.root = _GateRoot() self.root.on_callback_error = self._handle_callback_error self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) - self._status = tk.StringVar(master=self.root, value="") - self._slot_header = tk.StringVar(master=self.root, value="") - self._preview = tk.StringVar(master=self.root, value="") - self._projection = tk.StringVar(master=self.root, value="") - self._cal_headline = tk.StringVar(master=self.root, value="") - self._dashboard = tk.StringVar(master=self.root, value="") - self._meal_summary = tk.StringVar(master=self.root, value="") - self._unit = tk.StringVar(master=self.root, value=_UNIT_GRAMS) - self._desc_text: tk.Text - self._amount_entry: tk.Entry - self._per_entry: tk.Entry - self._basis_prefix: tk.Label - self._kcal_entry: tk.Entry - self._protein_entry: tk.Entry - self._carbs_entry: tk.Entry - self._fat_entry: tk.Entry - self._suggestion_box: tk.Listbox - self._meal_name_entry: tk.Entry - self._status_label: tk.Label + self._vars = make_vars(self.root) self._build() - # -- 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._desc_text.focus_force() - - # -- UI construction ---------------------------------------------------- - def _build(self) -> None: - """Lay out the lock UI, seed the first slot prompt, and grab input.""" + """Lay out the UI, wire events, seed the first prompt, and grab input.""" self._setup_window() - frame = tk.Frame(self.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=self._slot_header, - font=("Arial", 16, "bold"), - bg=_BG, - fg=_FG, - wraplength=900, - justify="center", - ).pack(pady=(0, 10)) - - self._build_desc(frame) - self._suggestion_box = self._build_suggestion_box(frame) - self._build_amount_row(frame) - self._build_macro_section(frame) - - for entry in (self._amount_entry, self._per_entry): - entry.bind("", self._on_return) - for entry in self._macro_entries(): - entry.bind("", self._on_return) - entry.bind("", self._on_macro_edit) - - tk.Label( - frame, - textvariable=self._projection, - font=("Arial", 13, "bold"), - bg=_BG, - fg=_FG, - wraplength=900, - justify="center", - ).pack(pady=(2, 2)) - - tk.Label( - frame, - textvariable=self._preview, - font=("Arial", 14, "bold"), - bg=_BG, - fg=_ACCENT, - wraplength=900, - justify="center", - ).pack(pady=(2, 6)) - - self._build_meal_controls(frame) - - tk.Button( - frame, - text="Log & Continue", - font=("Arial", 15, "bold"), - bg=_ACCENT, - fg="#003322", - activebackground="#00cc66", - cursor="hand2", - command=self._on_submit, - ).pack(pady=(4, 6)) - - self._status_label = tk.Label( - frame, - textvariable=self._status, - font=("Arial", 12), - bg=_BG, - fg=_FG, - wraplength=900, - justify="center", + callbacks = GateCallbacks( + on_unit_change=self._on_unit_change, + on_submit=self._on_submit, + on_close=self.close, + on_add_item=self._on_add_item, ) - self._status_label.pack() - - self._build_dashboard(frame) - - if self.demo_mode: - tk.Button( - self.root, - text="✕ Close Demo", - font=("Arial", 12), - bg="#ff4444", - fg="white", - command=self.close, - cursor="hand2", - ).place(x=10, y=10) - + self._widgets = build_layout( + self.root, + self._vars, + callbacks, + demo_mode=self.demo_mode, + ) + self._wire_events() self._relabel_basis() self._refresh_slot_header() self._refresh_dashboard() self._refresh_projection() self._grab_input() - self._desc_text.focus_set() + self._widgets.desc_text.focus_set() - def _build_desc(self, parent: tk.Frame) -> None: - """Build the wrapping, multi-line "what did you eat?" description box. + def _wire_events(self) -> None: + """Bind the live per-keystroke events to the freshly built widgets. - 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. + Construction-time commands (button and option-menu) are wired inside + ``build_layout``; the key bindings that drive lookup, scaling, and + submission are connected here, where the controller methods are in scope. """ - 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, + widgets = self._widgets + widgets.desc_text.bind("", self._on_desc_keyrelease) + widgets.desc_text.bind("", self._on_desc_return) + widgets.suggestion_box.bind( + "<>", + self._on_suggestion_select, ) - text.pack(pady=(2, 6)) - text.bind("", self._on_desc_keyrelease) - text.bind("", self._on_desc_return) - self._desc_text = text - - def _get_desc(self) -> str: - """Return the description text, trimmed (a Text always trails a newline).""" - return self._desc_text.get("1.0", "end-1c").strip() - - def _set_desc(self, value: str) -> None: - """Replace the description box's contents with ``value``.""" - self._desc_text.delete("1.0", tk.END) - if value: - self._desc_text.insert("1.0", value) + for entry in (widgets.amount_entry, widgets.per_entry): + entry.bind("", self._on_amount_change) + entry.bind("", self._on_return) + for entry in self._macro_entries(): + entry.bind("", self._on_return) + entry.bind("", self._on_macro_edit) def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str: """Submit on Enter in the description box, suppressing the newline.""" self._on_submit() return "break" - - def _numeric_entry(self, parent: tk.Frame, *, width: int) -> tk.Entry: - """Return an entry that only accepts a number or a blank string.""" - vcmd = (self.root.register(self._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, - ) - - @staticmethod - 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 _build_suggestion_box(self, 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.bind("<>", self._on_suggestion_select) - box.pack(pady=(0, 8)) - return box - - def _build_amount_row(self, parent: tk.Frame) -> None: - """Build the centered "how much did you eat?" amount + unit row.""" - 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)) - self._amount_entry = self._numeric_entry(row, width=10) - self._amount_entry.pack(side="left", ipady=3) - self._amount_entry.bind("", self._on_amount_change) - unit_menu = tk.OptionMenu( - row, - self._unit, - _UNIT_GRAMS, - _UNIT_ITEMS, - command=self._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)) - - def _build_macro_section(self, parent: tk.Frame) -> None: - """Build the per-basis field (grams or item weight) and macro row.""" - basis = tk.Frame(parent, bg=_BG) - basis.pack() - self._basis_prefix = tk.Label( - basis, - text=_BASIS_PREFIX_GRAMS, - font=("Arial", 12), - bg=_BG, - fg=_FG, - ) - self._basis_prefix.pack(side="left") - self._per_entry = self._numeric_entry(basis, width=5) - self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") - self._per_entry.pack(side="left", padx=4, ipady=2) - self._per_entry.bind("", self._on_amount_change) - 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)) - self._kcal_entry = self._macro_cell(row, "kcal") - self._protein_entry = self._macro_cell(row, "P") - self._carbs_entry = self._macro_cell(row, "C") - self._fat_entry = self._macro_cell(row, "F") - - def _macro_cell(self, 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 = self._numeric_entry(cell, width=7) - entry.pack(ipady=3) - return entry - - def _macro_entries(self) -> tuple[tk.Entry, ...]: - """Return the four numeric entry widgets in (kcal, P, C, F) order.""" - return ( - self._kcal_entry, - self._protein_entry, - self._carbs_entry, - self._fat_entry, - ) - - def _build_dashboard(self, parent: tk.Frame) -> 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=self._cal_headline, - font=("Arial", 22, "bold"), - bg=_BG, - fg=_ACCENT, - ).pack(pady=(12, 0)) - tk.Label( - parent, - textvariable=self._dashboard, - font=("Courier", 11), - bg=_BG, - fg=_MUTED, - justify="left", - anchor="w", - wraplength=900, - ).pack(pady=(2, 0)) - - def _build_meal_controls(self, parent: tk.Frame) -> None: - """Build the optional multi-item meal row: name, add button, running sum. - - 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") - self._meal_name_entry = tk.Entry( - row, - font=("Arial", 13), - width=18, - bg=_FIELD_BG, - fg=_FG, - insertbackground=_FG, - ) - self._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=self._on_add_item, - ).pack(side="left") - tk.Label( - parent, - textvariable=self._meal_summary, - font=("Arial", 11), - bg=_BG, - fg=_MUTED, - wraplength=900, - justify="center", - ).pack(pady=(0, 2)) - - # -- 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._slot_header.set("All meals logged.") - return - slot = self._pending[0] - position = "" if total == 1 else f" (1 of {total} remaining)" - self._slot_header.set(f"Log your {slot_label(slot)} meal{position}") - - def _clear_food_inputs(self) -> None: - """Empty the food fields, picker, preview, and basis (keeps any meal).""" - self._set_desc("") - self._amount_entry.delete(0, tk.END) - self._unit.set(_UNIT_GRAMS) - self._relabel_basis() - self._reset_per_default() - for entry in self._macro_entries(): - entry.delete(0, tk.END) - self._suggestion_box.delete(0, tk.END) - self._suggestions = [] - self._source = "manual" - self._last_reference = None - self._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._meal_items = [] - self._meal_name_entry.delete(0, tk.END) - self._meal_summary.set("") - - def _reset_per_default(self) -> None: - """Set the "per" field to the basis default for the current unit.""" - self._per_entry.delete(0, tk.END) - if self._unit.get() == _UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - self._per_entry.insert( - 0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}" - ) - else: - self._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._unit.get() == _UNIT_ITEMS - self._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._per_entry.get().strip()) - if typed is not None and typed > 0: - return typed - if self._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._amount_entry.get().strip()) - if amount is None: - return None - if self._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}") - - # -- 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._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._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._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._projection.set(f"{base} → after this item: {after:g} left") - else: - self._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._unit.get() == _UNIT_ITEMS: - grams = estimate_unit_grams(query) - if grams is not None: - self._set_entry(self._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._suggestion_mode = "bank" - self._suggestions = suggest_foods(query, limit=_SUGGESTION_ROWS) - self._suggestion_box.delete(0, tk.END) - for name, nutrition in self._suggestions: - self._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._suggestion_mode = "candidates" - self._suggestions = candidates - self._suggestion_box.delete(0, tk.END) - for label, nutrition in candidates: - self._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._suggestion_box.curselection() - if not selection: - return - index = selection[0] - if index >= len(self._suggestions): - return - name, nutrition = self._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._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._source = nutrition.source - self._last_reference = nutrition - if name is not None: - self._set_desc(name) - if self._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._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._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._amount_entry.get().strip() and nutrition.grams: - self._set_entry(self._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._amount_entry.delete(0, tk.END) - if self._last_reference is not None: - self._apply_reference(self._last_reference) - return - for entry in self._macro_entries(): - entry.delete(0, tk.END) - self._reset_per_default() - self._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._source = "manual" - self._last_reference = None - self._refresh_preview() - - # -- behaviour ---------------------------------------------------------- - - def _set_status(self, text: str, *, error: bool = False) -> None: - """Update the status line, red for errors.""" - self._status.set(text) - self._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._meal_items: - self._log_meal() - return - self._set_status("Type what you ate first.", error=True) - self._desc_text.focus_set() - return - - values = self._macro_values() - if values is None: - self._set_status("Macros must be numbers.", error=True) - self._kcal_entry.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._kcal_entry.focus_set() - return - if self._meal_items: - self._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._kcal_entry.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._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._meal_items: - self._meal_summary.set("") - return - total = meal_total(self._meal_items) - names = ", ".join(item.name for item in self._meal_items) - self._meal_summary.set( - f"Meal so far ({len(self._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._desc_text.focus_set() - return - values = self._macro_values() - if values is None: - self._set_status("Macros must be numbers.", error=True) - self._kcal_entry.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._kcal_entry.focus_set() - return - self._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._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._meal_items) - total = remember_meal(name, list(self._meal_items)) - log_meal(name, total, self._slot_for_log()) - self._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._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._cal_headline.set(self._cal_headline_text()) - self._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._kcal_entry.focus_set() - - # -- 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() diff --git a/diet_guard/_gatelock_core.py b/diet_guard/_gatelock_core.py new file mode 100644 index 0000000..e8f17e3 --- /dev/null +++ b/diet_guard/_gatelock_core.py @@ -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}") diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py new file mode 100644 index 0000000..359646f --- /dev/null +++ b/diet_guard/_gatelock_mealflow.py @@ -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() diff --git a/diet_guard/_gatelock_nutrition.py b/diet_guard/_gatelock_nutrition.py new file mode 100644 index 0000000..c25e219 --- /dev/null +++ b/diet_guard/_gatelock_nutrition.py @@ -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() diff --git a/diet_guard/_gatelock_support.py b/diet_guard/_gatelock_support.py new file mode 100644 index 0000000..f4c93d8 --- /dev/null +++ b/diet_guard/_gatelock_support.py @@ -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) diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py new file mode 100644 index 0000000..ea3e5a8 --- /dev/null +++ b/diet_guard/_gatelock_ui.py @@ -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, + ) diff --git a/diet_guard/_gatelock_window.py b/diet_guard/_gatelock_window.py new file mode 100644 index 0000000..34aa01a --- /dev/null +++ b/diet_guard/_gatelock_window.py @@ -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() diff --git a/diet_guard/_state.py b/diet_guard/_state.py index f29bd72..68dd910 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING 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.shared.coerce import as_float from python_pkg.shared.log_integrity import ( compute_entry_hmac, verify_entry_hmac, @@ -58,12 +59,7 @@ def _entry_float(entry: dict[str, object], key: str) -> float: Returns: The field as a float, or 0.0 when absent or not a real number. """ - value = entry.get(key) - if isinstance(value, bool): - return 0.0 - if isinstance(value, (int, float)): - return float(value) - return 0.0 + return as_float(entry.get(key)) def entry_kcal(entry: dict[str, object]) -> float: diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index de83026..3ae64c3 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -7,15 +7,33 @@ Two safety nets run for every test: * ``_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 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 contextlib import ExitStack +from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch 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: from collections.abc import Iterator 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") with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key): 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") diff --git a/diet_guard/tests/test_foodbank.py b/diet_guard/tests/test_foodbank.py index 9687688..ae13bd4 100644 --- a/diet_guard/tests/test_foodbank.py +++ b/diet_guard/tests/test_foodbank.py @@ -35,22 +35,6 @@ def _write_raw(bank: object) -> None: _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: """Round-tripping foods through the bank.""" diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index 5573ccc..15ec60d 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -1,9 +1,9 @@ """Tests for _gatelock.py — the fullscreen log-to-unlock gate window. -A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a -real ``TclError``) replaces the conftest's blanket MagicMock for the duration of -each gate test, so the window's *logic* runs for real against in-memory widgets -without ever opening a window or grabbing the keyboard. +Window mechanics, construction, and the shared module-level helpers. The +nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the +functional fake ``tk`` widgets and the ``gate`` fixture live in +``conftest.py`` and are shared by both files. """ from __future__ import annotations @@ -13,159 +13,32 @@ from unittest.mock import MagicMock, patch 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._estimator import Nutrition from python_pkg.diet_guard._gatelock import ( MealGate, - _format_preview, _pending_slots, - _safe_float, acquire_gate_lock, release_gate_lock, - wait_for_display, ) -from python_pkg.diet_guard._meal import MealItem +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 # class (not the conftest MagicMock) is available for its callback-error test. _REAL_GATE_ROOT = _gatelock._GateRoot -class _FakeTclError(Exception): - """Stand-in for ``tkinter.TclError`` (a real, catchable exception).""" - - -class FakeVar: - """A functional ``StringVar``: stores and returns a string.""" - - def __init__(self, master: object = None, value: str = "") -> None: - self._value = value - - def get(self) -> str: - return self._value - - def set(self, value: str) -> None: - self._value = value - - -class FakeEntry: - """A functional one-line entry (delete clears, insert appends).""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self._value = "" - - def get(self) -> str: - return self._value - - def delete(self, first: object, last: object = None) -> None: - self._value = "" - - def insert(self, index: object, text: str) -> None: - self._value += text - - def pack(self, *args: object, **kwargs: object) -> FakeEntry: - return self - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - def configure(self, *args: object, **kwargs: object) -> None: - pass - - config = configure - - def focus_set(self) -> None: - pass - - def focus_force(self) -> None: - pass - - -class FakeText(FakeEntry): - """A functional multi-line text box (``get`` ignores the index range).""" - - def get(self, start: object = None, end: object = None) -> str: - return self._value - - -class FakeListbox: - """A functional listbox tracking items and the current selection.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self._items: list[str] = [] - self._sel: tuple[int, ...] = () - - def delete(self, first: object, last: object = None) -> None: - self._items = [] - - def insert(self, index: object, text: str) -> None: - self._items.append(text) - - def curselection(self) -> tuple[int, ...]: - return self._sel - - def selection_set(self, index: int) -> None: - self._sel = (index,) - - def selection_clear(self, first: object, last: object = None) -> None: - self._sel = () - - def pack(self, *args: object, **kwargs: object) -> FakeListbox: - return self - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - -class FakeWidget: - """A generic no-op widget for Frame/Label/Button/OptionMenu.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - def pack(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - def place(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - def configure(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - config = configure - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - -_FAKE_TK = SimpleNamespace( - END="end", - TclError=_FakeTclError, - StringVar=FakeVar, - Frame=FakeWidget, - Label=FakeWidget, - Button=FakeWidget, - OptionMenu=FakeWidget, - Entry=FakeEntry, - Text=FakeText, - Listbox=FakeListbox, - Event=object, -) - - -@pytest.fixture -def gate() -> object: - """Build a demo gate whose widgets are functional fakes.""" - with patch.object(_gatelock, "tk", _FAKE_TK): - yield MealGate(demo_mode=True) - - -def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition: - """A simple reference nutrition for driving the form.""" - return Nutrition(kcal, 10, 20, 5, grams, "food bank") - - # -------------------------------------------------------------------------- # Module-level helpers # -------------------------------------------------------------------------- @@ -268,13 +141,13 @@ class TestConstruction: def test_demo_builds(self, gate: MealGate) -> None: """A demo gate constructs with a pending slot and grams basis.""" assert gate.demo_mode is True - assert gate._unit.get() == "grams" + assert gate._vars.unit.get() == "grams" def test_production_builds(self) -> None: """A production gate disables VT switching and grabs input.""" with ( patch.object(_gatelock, "tk", _FAKE_TK), - patch.object(_gatelock.shutil, "which", return_value=None), + patch.object(_gatelock_window.shutil, "which", return_value=None), ): gate = MealGate(demo_mode=False) assert gate.demo_mode is False @@ -288,11 +161,11 @@ class TestConstruction: class TestFormBasics: """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.""" - assert gate._is_numeric_or_blank("") - assert gate._is_numeric_or_blank("12.5") - assert not gate._is_numeric_or_blank("abc") + assert _gatelock_ui.is_numeric_or_blank("") + assert _gatelock_ui.is_numeric_or_blank("12.5") + assert not _gatelock_ui.is_numeric_or_blank("abc") def test_desc_get_set(self, gate: MealGate) -> None: """The description round-trips through its helpers, trimmed.""" @@ -308,264 +181,81 @@ class TestFormBasics: def test_macro_values_non_numeric(self, gate: MealGate) -> None: """A non-numeric macro field makes the whole read None.""" - gate._kcal_entry.insert(0, "abc") + gate._widgets.macros.kcal.insert(0, "abc") assert gate._macro_values() is None -class TestReferenceModel: - """The reference -> total nutrition computation.""" +class TestBasisAndAmount: + """Edge branches in the grams/items basis and amount maths.""" - 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_basis_typed_value(self, gate: MealGate) -> None: + """A typed per-value is honoured directly.""" + gate._set_entry(gate._widgets.per_entry, "50") + assert gate._basis_grams() == 50 - def test_current_is_reference_without_amount(self, gate: MealGate) -> None: - """With calories but no amount, the reference stands in as the total.""" - gate._kcal_entry.insert(0, "200") - current = gate._current_nutrition() - assert current is not None - assert current.kcal == 200 + def test_basis_items_known_staple(self, gate: MealGate) -> None: + """Items mode with a blank per falls back to the staple weight.""" + gate._widgets.per_entry.delete(0) + gate._vars.unit.set("items") + gate._set_desc("apple") + assert gate._basis_grams() == 182 - def test_current_scales_with_amount(self, gate: MealGate) -> None: - """Grams eaten scale the per-100 g reference into the total.""" - gate._kcal_entry.insert(0, "200") - gate._amount_entry.insert(0, "200") - current = gate._current_nutrition() - assert current is not None - assert current.kcal == 400 + def test_basis_items_unknown(self, gate: MealGate) -> None: + """An unknown item uses the default piece weight.""" + gate._widgets.per_entry.delete(0) + gate._vars.unit.set("items") + gate._set_desc("mystery") + assert gate._basis_grams() == 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._widgets.per_entry.delete(0) + assert gate._basis_grams() == DEFAULT_PER_GRAMS -class TestSuggestions: - """Autocomplete population and selection.""" + def test_eaten_grams_none(self, gate: MealGate) -> None: + """No amount typed yields no eaten weight.""" + assert gate._eaten_grams() is None - def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None: - """In items mode, typing a staple fills the per-item weight.""" - gate._unit.set("items") + def test_eaten_grams_items(self, gate: MealGate) -> None: + """Items mode multiplies the count by the per-item weight.""" + 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._on_desc_keyrelease(None) - assert gate._per_entry.get() == "182" - def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None: - """Picking a banked suggestion adopts its name and macros.""" - gate._suggestions = [("apple pie", _nutrition(300, 120))] - gate._suggestion_mode = "bank" - gate._suggestion_box.selection_set(0) - gate._on_suggestion_select(None) - assert gate._get_desc() == "apple pie" - assert gate._kcal_entry.get() == "300" + def test_keyrelease_items_unknown(self, gate: MealGate) -> None: + """An unknown item in items mode leaves the per field unchanged.""" + gate._vars.unit.set("items") + gate._set_desc("zzzz") + gate._on_desc_keyrelease(None) - def test_select_candidate_keeps_description(self, gate: MealGate) -> None: - """An OFF candidate fills macros but not the typed description.""" - gate._set_desc("my dish") - gate._suggestions = [("openfoodfacts: X", _nutrition(250, 100))] - gate._suggestion_mode = "candidates" - gate._suggestion_box.selection_set(0) - gate._on_suggestion_select(None) - assert gate._get_desc() == "my dish" - - def test_select_no_selection(self, gate: MealGate) -> None: - """No selection is a no-op.""" - gate._on_suggestion_select(None) - - def test_select_out_of_range(self, gate: MealGate) -> None: - """A stale selection index beyond the list is ignored.""" - gate._suggestions = [] - gate._suggestion_box.selection_set(5) - gate._on_suggestion_select(None) - - -class TestUnitToggle: - """Switching the grams/items basis.""" - - def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None: - """A picked food is re-expressed per item, then back per 100 g.""" - gate._apply_reference(_nutrition(52, 100), name="apple") - gate._unit.set("items") - gate._on_unit_change("items") - per_item = gate._kcal_entry.get() - gate._unit.set("grams") - gate._on_unit_change("grams") - assert gate._kcal_entry.get() == "52" - assert per_item != "52" - - def test_toggle_without_reference_clears(self, gate: MealGate) -> None: - """With no picked food, a toggle clears the macro fields.""" - gate._kcal_entry.insert(0, "123") - gate._last_reference = None - gate._unit.set("items") - gate._on_unit_change("items") - assert gate._kcal_entry.get() == "" - - def test_macro_edit_drops_reference(self, gate: MealGate) -> None: - """Hand-editing a macro invalidates the stored reference.""" - gate._last_reference = _nutrition() - gate._on_macro_edit(None) - assert gate._last_reference is None - - -class TestSubmit: - """The two-step submit (look up, then log).""" - - def test_empty_description(self, gate: MealGate) -> None: - """Submitting with no description prompts for one.""" - gate._on_submit() - assert "Type what you ate" in gate._status.get() - - def test_non_numeric_macros(self, gate: MealGate) -> None: - """Non-numeric macros are rejected before logging.""" - gate._set_desc("apple") - gate._kcal_entry.insert(0, "abc") - gate._on_submit() - assert "must be numbers" in gate._status.get() - - def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None: - """A blank calorie field looks the food up rather than logging.""" - gate._set_desc("apple") - with patch.object(gate, "_begin_lookup") as lookup: - gate._on_submit() - lookup.assert_called_once() - - def test_defensive_none_nutrition(self, gate: MealGate) -> None: - """A calorie value but unresolvable nutrition prompts again (guard).""" - gate._set_desc("apple") - gate._kcal_entry.insert(0, "200") - with patch.object(gate, "_current_nutrition", return_value=None): - gate._on_submit() - assert "Enter the calories" in gate._status.get() - - def test_valid_submit_records(self, gate: MealGate) -> None: - """A described, priced meal is recorded.""" - gate._set_desc("apple") - gate._kcal_entry.insert(0, "95") - with patch.object(gate, "_record") as record: - gate._on_submit() - record.assert_called_once() - - def test_on_return_submits(self, gate: MealGate) -> None: - """Enter in a numeric field submits.""" - with patch.object(gate, "_on_submit") as submit: - gate._on_return(None) - submit.assert_called_once() - - -class TestLookup: - """Step one: filling the form from a lookup.""" - - def test_no_candidates(self, gate: MealGate) -> None: - """No match asks for a manual value.""" - gate._set_desc("nonsense") - with patch.object(_gatelock, "lookup_candidates", return_value=[]): - gate._begin_lookup("nonsense") - assert "Couldn't look that up" in gate._status.get() - - def test_single_candidate(self, gate: MealGate) -> None: - """A single match fills the fields and invites review.""" - with patch.object( - _gatelock, - "lookup_candidates", - return_value=[("apple", _nutrition(95, 100))], - ): - gate._begin_lookup("apple") - assert "Review the values" in gate._status.get() - - def test_multiple_candidates(self, gate: MealGate) -> None: - """Several matches invite picking another.""" - with patch.object( - _gatelock, - "lookup_candidates", - return_value=[ - ("a", _nutrition(95, 100)), - ("b", _nutrition(120, 100)), - ], - ): - gate._begin_lookup("apple") - assert "pick another" in gate._status.get() - - -class TestRecord: - """Logging a meal and advancing the slot walk.""" - - def test_demo_logs_without_slot(self, gate: MealGate) -> None: - """A demo record banks the food but tags no real slot.""" - gate._pending = [8] - with patch.object(_gatelock, "log_meal") as log: - gate._record("apple", _nutrition(95, 100)) - assert log.call_args.args[2] is None - - def test_last_slot_unlocks(self, gate: MealGate) -> None: - """Recording the final pending slot triggers the unlock.""" - gate._pending = [8] - with ( - patch.object(_gatelock, "log_meal"), - patch.object(_gatelock, "remember_food"), - patch.object(gate, "_unlock") as unlock, - ): - gate._record("apple", _nutrition(95, 100)) - unlock.assert_called_once() - - def test_more_slots_continue(self, gate: MealGate) -> None: - """With slots remaining, the form clears and prompts the next.""" - gate._pending = [8, 12] - with ( - patch.object(_gatelock, "log_meal"), - patch.object(_gatelock, "remember_food"), - ): - gate._record("apple", _nutrition(95, 100)) - assert gate._pending == [12] - assert "next meal" in gate._status.get() - - def test_unlock_schedules_close(self, gate: MealGate) -> None: - """Unlock sets the closing status and schedules teardown.""" - gate._unlock("logged X") - assert "unlocking" in gate._status.get() - - -class TestDashboard: - """The running calorie/macro panel.""" - - def test_headline_with_budget(self, gate: MealGate) -> None: - """A sealed budget shows consumed/target/remaining.""" - seal_budget(2000) - gate._refresh_dashboard() - assert "left" in gate._cal_headline.get() - - def test_headline_without_budget(self, gate: MealGate) -> None: - """With no budget, only today's total is shown.""" - gate._refresh_dashboard() - assert "kcal today" in gate._cal_headline.get() - - def test_dashboard_lists_entries(self, gate: MealGate) -> None: - """Logged entries appear in the detail panel.""" - seal_budget(2000, weight_kg=80) - _gatelock.log_meal("apple", _nutrition(95, 100), 8) - gate._refresh_dashboard() - text = gate._dashboard.get() - assert "apple" in text - assert "protein" in text - - def test_dashboard_empty(self, gate: MealGate) -> None: - """With nothing logged, the panel says so.""" - gate._refresh_dashboard() - assert "nothing logged yet" in gate._dashboard.get() - - def test_slot_header_variants(self, gate: MealGate) -> None: - """The header covers none / one / several pending slots.""" - gate._pending = [] - gate._refresh_slot_header() - assert "All meals logged" in gate._slot_header.get() - gate._pending = [8] - gate._refresh_slot_header() - assert "Log your" in gate._slot_header.get() - gate._pending = [8, 12] - gate._refresh_slot_header() - assert "remaining" in gate._slot_header.get() - - def test_projection_with_budget(self, gate: MealGate) -> None: - """The projection shows the after-this-item remaining when priced.""" - seal_budget(2000) - gate._kcal_entry.insert(0, "300") - gate._refresh_projection() - assert "after this item" in gate._projection.get() + 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._widgets.amount_entry, "50") + gate._apply_reference(_nutrition(100, 100)) + assert gate._widgets.amount_entry.get() == "50" class TestWindowMechanics: @@ -573,15 +263,15 @@ class TestWindowMechanics: def test_disable_vt_no_tool(self, gate: MealGate) -> None: """A missing setxkbmap leaves VT switching enabled.""" - with patch.object(_gatelock.shutil, "which", return_value=None): + with patch.object(_gatelock_window.shutil, "which", return_value=None): gate._disable_vt_switching() assert gate._vt_disabled is False def test_disable_and_restore_vt(self, gate: MealGate) -> None: """With the tool present, VT switching toggles off then back on.""" with ( - patch.object(_gatelock.shutil, "which", return_value="/x/setxkbmap"), - patch.object(_gatelock.subprocess, "run") as run, + patch.object(_gatelock_window.shutil, "which", return_value="/x/setxkbmap"), + patch.object(_gatelock_window.subprocess, "run") as run, ): gate._disable_vt_switching() assert gate._vt_disabled is True @@ -603,7 +293,7 @@ class TestWindowMechanics: """A held grab reschedules another attempt instead of giving up.""" gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError) gate.root.after = MagicMock() - gate._acquire_global_grab(attempt=_gatelock._GRAB_LOG_EVERY) + gate._acquire_global_grab(attempt=_GRAB_LOG_EVERY) gate.root.after.assert_called_once() 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.""" gate.root.mainloop = MagicMock() with ( - patch.object(_gatelock.signal, "signal"), - patch.object(_gatelock.atexit, "register"), + patch.object(_gatelock_window.signal, "signal"), + patch.object(_gatelock_window.atexit, "register"), ): gate.run() gate.root.mainloop.assert_called_once() @@ -640,12 +330,12 @@ class TestWindowMechanics: def test_callback_error_status(self, gate: MealGate) -> None: """An unexpected callback error surfaces a recoverable message.""" gate._handle_callback_error() - assert "went wrong" in gate._status.get() + assert "went wrong" in gate._vars.status.get() def test_restore_vt_without_tool(self, gate: MealGate) -> None: """Restoring when the tool has since vanished still clears the flag.""" gate._vt_disabled = True - with patch.object(_gatelock.shutil, "which", return_value=None): + with patch.object(_gatelock_window.shutil, "which", return_value=None): gate._restore_vt_switching() assert gate._vt_disabled is False @@ -657,87 +347,14 @@ class TestWindowMechanics: gate.root.after.assert_called_once() -class TestBasisAndAmount: - """Edge branches in the grams/items basis and amount maths.""" - - def test_basis_typed_value(self, gate: MealGate) -> None: - """A typed per-value is honoured directly.""" - gate._set_entry(gate._per_entry, "50") - assert gate._basis_grams() == 50 - - def test_basis_items_known_staple(self, gate: MealGate) -> None: - """Items mode with a blank per falls back to the staple weight.""" - gate._per_entry.delete(0) - gate._unit.set("items") - gate._set_desc("apple") - assert gate._basis_grams() == 182 - - def test_basis_items_unknown(self, gate: MealGate) -> None: - """An unknown item uses the default piece weight.""" - gate._per_entry.delete(0) - gate._unit.set("items") - gate._set_desc("mystery") - assert gate._basis_grams() == _gatelock.DEFAULT_ITEM_GRAMS - - def test_basis_grams_default(self, gate: MealGate) -> None: - """Grams mode with a blank per uses the per-100 g default.""" - gate._per_entry.delete(0) - assert gate._basis_grams() == _gatelock._DEFAULT_PER_GRAMS - - def test_eaten_grams_none(self, gate: MealGate) -> None: - """No amount typed yields no eaten weight.""" - assert gate._eaten_grams() is None - - def test_eaten_grams_items(self, gate: MealGate) -> None: - """Items mode multiplies the count by the per-item weight.""" - gate._unit.set("items") - gate._set_desc("apple") - gate._set_entry(gate._per_entry, "182") - gate._set_entry(gate._amount_entry, "5") - assert gate._eaten_grams() == 5 * 182 - - def test_amount_change_refreshes(self, gate: MealGate) -> None: - """Changing the amount recomputes the preview.""" - gate._set_entry(gate._kcal_entry, "100") - gate._set_entry(gate._amount_entry, "200") - gate._on_amount_change(None) - assert gate._preview.get() - - def test_projection_else_without_item(self, gate: MealGate) -> None: - """With a budget but no priced item, no after-this-item is shown.""" - seal_budget(2000) - gate._refresh_projection() - text = gate._projection.get() - assert "left" in text - assert "after this item" not in text - - def test_keyrelease_grams_mode(self, gate: MealGate) -> None: - """In grams mode the per-item weight is not touched on keyrelease.""" - gate._unit.set("grams") - gate._set_desc("apple") - gate._on_desc_keyrelease(None) - - def test_keyrelease_items_unknown(self, gate: MealGate) -> None: - """An unknown item in items mode leaves the per field unchanged.""" - gate._unit.set("items") - gate._set_desc("zzzz") - gate._on_desc_keyrelease(None) - - def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None: - """A grams-mode pick does not overwrite an amount already typed.""" - gate._set_entry(gate._amount_entry, "50") - gate._apply_reference(_nutrition(100, 100)) - assert gate._amount_entry.get() == "50" - - class TestDisplayReadiness: """The session-start display wait that absorbs the X auth-cookie race.""" def test_ready_when_root_connects(self) -> None: """A Tk root that builds and destroys cleanly means the display is up.""" fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError) - with patch.object(_gatelock, "tk", fake_tk): - assert _gatelock._display_is_ready() is True + with patch.object(_gatelock_support, "tk", fake_tk): + assert _gatelock_support._display_is_ready() is True fake_tk.Tk.return_value.destroy.assert_called_once() def test_not_ready_on_tclerror(self) -> None: @@ -746,13 +363,13 @@ class TestDisplayReadiness: Tk=MagicMock(side_effect=_FakeTclError("no display")), TclError=_FakeTclError, ) - with patch.object(_gatelock, "tk", fake_tk): - assert _gatelock._display_is_ready() is False + with patch.object(_gatelock_support, "tk", fake_tk): + assert _gatelock_support._display_is_ready() is False def test_wait_returns_immediately_when_ready(self) -> None: """A display ready on the first probe returns at once and never sleeps.""" sleep = MagicMock() - with patch.object(_gatelock, "_display_is_ready", return_value=True): + with patch.object(_gatelock_support, "_display_is_ready", return_value=True): ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0)) assert ready is True sleep.assert_not_called() @@ -761,7 +378,9 @@ class TestDisplayReadiness: """Not-ready then ready sleeps once between probes, then unblocks.""" sleep = MagicMock() monotonic = MagicMock(side_effect=[0.0, 0.0]) - with patch.object(_gatelock, "_display_is_ready", side_effect=[False, True]): + with patch.object( + _gatelock_support, "_display_is_ready", side_effect=[False, True] + ): assert wait_for_display(sleep=sleep, monotonic=monotonic) is True sleep.assert_called_once() @@ -769,149 +388,6 @@ class TestDisplayReadiness: """A display still down at the deadline gives up so the next tick retries.""" sleep = MagicMock() monotonic = MagicMock(side_effect=[0.0, 60.0]) - with patch.object(_gatelock, "_display_is_ready", return_value=False): + with patch.object(_gatelock_support, "_display_is_ready", return_value=False): assert wait_for_display(sleep=sleep, monotonic=monotonic) is False sleep.assert_not_called() - - -class TestMealFlow: - """Building and logging a multi-item composite meal.""" - - def test_meal_name_trimmed(self, gate: MealGate) -> None: - """The meal name is read back trimmed.""" - gate._meal_name_entry.insert(0, " dinner ") - assert gate._meal_name() == "dinner" - - def test_summary_empty_with_no_items(self, gate: MealGate) -> None: - """With no accumulated items the running summary is blank.""" - gate._refresh_meal_summary() - assert gate._meal_summary.get() == "" - - def test_summary_lists_items_and_total(self, gate: MealGate) -> None: - """The summary shows the item names and the running calorie total.""" - gate._meal_items = [ - MealItem("salad", _nutrition(80, 120)), - MealItem("chicken", _nutrition(330, 200)), - ] - gate._refresh_meal_summary() - summary = gate._meal_summary.get() - assert "salad, chicken" in summary - assert "410 kcal" in summary - - def test_add_item_requires_description(self, gate: MealGate) -> None: - """Adding with no description prompts for one.""" - gate._on_add_item() - assert "Type the item first" in gate._status.get() - - def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None: - """Non-numeric macros are rejected before adding.""" - gate._set_desc("salad") - gate._kcal_entry.insert(0, "abc") - gate._on_add_item() - assert "must be numbers" in gate._status.get() - - def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None: - """A blank calorie field looks the item up rather than adding.""" - gate._set_desc("salad") - with patch.object(gate, "_begin_lookup") as lookup: - gate._on_add_item() - lookup.assert_called_once() - - def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None: - """A priced item that will not resolve prompts again (guard).""" - gate._set_desc("salad") - gate._kcal_entry.insert(0, "80") - with patch.object(gate, "_current_nutrition", return_value=None): - gate._on_add_item() - assert "add the item" in gate._status.get() - - def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None: - """A valid item is appended, the form clears, the meal name is kept.""" - gate._meal_name_entry.insert(0, "dinner") - gate._set_desc("salad") - gate._kcal_entry.insert(0, "80") - gate._on_add_item() - assert len(gate._meal_items) == 1 - assert gate._meal_items[0].name == "salad" - assert gate._get_desc() == "" - assert gate._meal_name() == "dinner" - assert "Added salad" in gate._status.get() - - def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None: - """Submitting an empty form with items finalizes the meal.""" - gate._meal_items = [MealItem("salad", _nutrition(80, 120))] - with patch.object(gate, "_log_meal") as log_meal_: - gate._on_submit() - log_meal_.assert_called_once() - - def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None: - """A filled form plus existing items adds the form item, then logs.""" - gate._meal_items = [MealItem("salad", _nutrition(80, 120))] - gate._set_desc("rice") - gate._kcal_entry.insert(0, "260") - with patch.object(gate, "_log_meal") as log_meal_: - gate._on_submit() - assert len(gate._meal_items) == 2 - assert gate._meal_items[1].name == "rice" - log_meal_.assert_called_once() - - def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None: - """Logging a meal banks it under the typed name and advances the slot.""" - gate._pending = [8, 12] - gate._meal_name_entry.insert(0, "dinner") - gate._meal_items = [ - MealItem("salad", _nutrition(80, 120)), - MealItem("chicken", _nutrition(330, 200)), - ] - with ( - patch.object( - _gatelock, "remember_meal", return_value=_nutrition(410, 320) - ) as remember, - patch.object(_gatelock, "log_meal") as log, - ): - gate._log_meal() - assert remember.call_args.args[0] == "dinner" - assert log.call_args.args[0] == "dinner" - assert gate._meal_items == [] - assert gate._pending == [12] - - def test_log_meal_uses_default_name(self, gate: MealGate) -> None: - """A blank meal name falls back to the default.""" - gate._pending = [8, 12] - gate._meal_items = [MealItem("soup", _nutrition(150, 300))] - with ( - patch.object( - _gatelock, "remember_meal", return_value=_nutrition(150, 300) - ) as remember, - patch.object(_gatelock, "log_meal"), - ): - gate._log_meal() - assert remember.call_args.args[0] == _gatelock._DEFAULT_MEAL_NAME - - def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None: - """A demo gate tags logs with no real slot.""" - gate._pending = [8] - assert gate._slot_for_log() is None - - def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None: - """A production gate tags logs with the current slot.""" - gate.demo_mode = False - gate._pending = [12] - assert gate._slot_for_log() == 12 - - def test_clear_inputs_discards_meal(self, gate: MealGate) -> None: - """Clearing between slots drops the in-progress meal and its name.""" - gate._meal_items = [MealItem("salad", _nutrition(80, 120))] - gate._meal_name_entry.insert(0, "dinner") - gate._meal_summary.set("something") - gate._clear_inputs() - assert gate._meal_items == [] - assert gate._meal_name() == "" - assert gate._meal_summary.get() == "" - - def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None: - """Finishing the final slot triggers unlock.""" - gate._pending = [20] - with patch.object(gate, "_unlock") as unlock: - gate._finish_slot("done") - unlock.assert_called_once() diff --git a/diet_guard/tests/test_gatelock_mealflow.py b/diet_guard/tests/test_gatelock_mealflow.py new file mode 100644 index 0000000..3ff9173 --- /dev/null +++ b/diet_guard/tests/test_gatelock_mealflow.py @@ -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()