diet-guard/diet_guard/_gatelock_nutrition.py
Krzysztof kuhy Rudnicki af461d3157 Split _state.py and _tk_fakes.py; make the food-log redirect fail closed
Eighth batch of the 250-line sweep (over-cap count 28 -> 25).

  _state.py       366 -> 197  (+ _state_today.py, _state_sync.py)
  test_state.py   400 -> 221  (+ test_state_today.py, test_state_sync.py)
  _tk_fakes.py    368 -> 167  (+ _tk_fakes_containers.py, _tk_fakes_canvas.py)

_state.py is the file conftest patches FOOD_LOG_FILE on, so the split is
constrained rather than free: `FOOD_LOG_FILE` and the two functions that open
it stay put, and the derived read model (_state_today) and the sync/undo
helpers (_state_sync) reach the file only through _state's private
_read_raw_log/_write_log. Callers were rewired to import from the new modules
directly rather than via a circular re-export.

That constraint is now enforced instead of merely documented. Moving a reader
of FOOD_LOG_FILE into a sibling would leave conftest's patch pointing at a
name nothing reads: check_patch_targets.py still exits 0 (the path resolves)
and the runtime is unchanged (a file write, not a network call), so the suite
would go green while writing to the real ~/.local/share/diet_guard and the
next sync tick would publish it. test_state_redirect.py asserts both halves --
only _constants and _state may name the constant, and the redirect
demonstrably moves a real write off the live path. Verified it fails closed by
adding a FOOD_LOG_FILE reader to _state_today: it named that module exactly.

_tk_fakes.py keeps _FAKE_TK/_FAKE_TTK and gains an explicit __all__, since the
re-exports are load-bearing and ruff would otherwise strip them as unused. The
identity canary from the previous commit covers the split: breaking one entry
in the assembly (Canvas=object) fails 8 gate tests loudly rather than silently
building real widgets.

Verified: 706 Python tests / 100% branch coverage, check_patch_targets 0,
pre-commit clean, ~/.local/share/diet_guard byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 20:21:26 +02:00

233 lines
10 KiB
Python

"""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:`~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 diet_guard._budget import BudgetError, daily_budget
from diet_guard._estimator import Nutrition, scale_nutrition
from diet_guard._gatelock_core import _GateCore
from diet_guard._gatelock_ui import (
DEFAULT_PER_GRAMS,
SUGGESTION_ROWS,
UNIT_ITEMS,
)
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from diet_guard._resolve import suggest_foods
from diet_guard._state_today 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 set 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()