feat(diet_guard): add meal-logging screen-lock gate with trigger fix

Add the diet_guard package: a screen-locking meal-logging gate that fires
on 4-hour slots (08/12/16/20) and records calories/macros, persisting an
autocompleting food bank.

- Trigger fix: the systemd timer fires at session start (Persistent=true)
  before lightdm has written ~/.Xauthority, so the gate crashed with a
  TclError instead of locking the screen. Add wait_for_display() /
  _display_is_ready() in _gatelock.py and wire it into _cli._cmd_gate so the
  gate retries on the next tick instead of crashing; add
  Environment=XAUTHORITY=%h/.Xauthority to the service as belt-and-suspenders.
- Food-bank hardening: a transiently corrupt food_bank.json was warned about
  on every keystroke and then silently overwritten (data loss). _read_bank
  now quarantines it via _quarantine_corrupt_bank() (warn-once + timestamped
  backup) before starting fresh.
- Multi-item meals: new _meal.py (MealItem, meal_total, MEAL_SOURCE),
  remember_meal() + _upsert() in _foodbank.py, and a "+ Add item" control in
  the gate that logs both the individual items and the composite meal.
- Bundle resolve_nutrition's manual macros into a ManualMacros dataclass to
  stay within the argument-count limit.

diet_guard at 100% branch coverage; full pre-commit suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-10 22:31:18 +02:00
commit 400f89b469
34 changed files with 6661 additions and 0 deletions

12
diet_guard/__init__.py Normal file
View File

@ -0,0 +1,12 @@
"""diet_guard: log what you eat, see the daily number, gate the desktop.
The package has three layers, built in order:
* the tracker (this milestone): a low-friction CLI that estimates the
calories of a free-text meal and appends a tamper-evident entry to a
per-day log;
* the gate (next): a screen-lock that will not dismiss until a recent meal
is logged -- the "log-to-unlock" enforcement;
* the escalation (later): a daily report that tightens the other personal
enforcers (games, PC uptime) when logging is skipped or the budget blown.
"""

10
diet_guard/__main__.py Normal file
View File

@ -0,0 +1,10 @@
"""Module entry point: ``python -m python_pkg.diet_guard``."""
from __future__ import annotations
import sys
from python_pkg.diet_guard._cli import main
if __name__ == "__main__":
sys.exit(main())

317
diet_guard/_budget.py Normal file
View File

@ -0,0 +1,317 @@
"""Hidden, tamper-hardened daily calorie budget for diet_guard.
This mirrors how ``phone_focus_mode`` hides the home GPS coordinates: the real
number never lives in committed source and is never printed. It is computed
once from biometrics at ``init`` time, written to a runtime-only file in the
XDG data dir (git-ignored, exactly like the phone's ``config_secrets.sh``), and
locked with ``chattr +i`` so changing it costs a deliberate ``sudo`` step.
Honest threat model (the same one the phone accepts):
* The point is **friction in a weak moment**, not cryptographic secrecy. The
Mifflin-St Jeor formula is right here in source and you know your own weight,
so a determined you can always recompute the number -- just as a determined
you can root the phone and read the coordinates.
* The shared HMAC key at ``/etc/workout-locker/hmac.key`` is world-readable, so
the signature defeats a *naive* edit (and detects disk corruption) but not
someone who reads the key and re-signs. The signature is tamper-*evidence*,
not a tamper-*lock*.
* The real lock is ``chattr +i``: removing the immutable bit needs root, which
is the actual speed bump. The strongest layer of all is simply that the
value is never displayed, so there is no on-screen anchor to fixate on.
"""
from __future__ import annotations
import base64
import binascii
from dataclasses import dataclass
import hmac
import json
import logging
from python_pkg.diet_guard._constants import BUDGET_FILE
from python_pkg.shared.log_integrity import compute_entry_hmac
_logger = logging.getLogger(__name__)
# Schema version stored inside the sealed blob, so a future format change can be
# detected rather than silently misread. v2 adds the optional body weight (``w``)
# used to derive a protein target; v1 seals (budget only) still read correctly.
_SEAL_VERSION = 2
# A medically sane lower bound. Even an aggressive deficit must not seal a
# starvation-level target, so the computed value is floored here.
_MIN_SANE_BUDGET = 1200
# Daily protein target for an active adult holding muscle on a deficit, in grams
# per kg of body weight. Used only to show a target in the dashboard; it has no
# part in the sealed calorie budget maths.
PROTEIN_G_PER_KG = 1.8
# Probe payload used only to check whether the shared HMAC key can be loaded.
_KEY_PROBE: dict[str, object] = {"_probe": True}
class BudgetError(Exception):
"""Base class for all budget-access failures."""
class BudgetNotInitializedError(BudgetError):
"""Raised when no sealed budget exists yet (``init`` never run)."""
def __init__(self) -> None:
"""Initialize with a fixed, side-effect-free message."""
super().__init__("daily budget has not been initialized")
class BudgetSealBrokenError(BudgetError):
"""Raised when the sealed budget is unreadable, corrupt, or tampered."""
def __init__(self) -> None:
"""Initialize with a fixed, side-effect-free message."""
super().__init__("daily budget seal is broken (tampered or corrupted)")
class BudgetLockedError(BudgetError):
"""Raised when the budget file is immutable and cannot be rewritten."""
def __init__(self) -> None:
"""Initialize with a fixed, side-effect-free message."""
super().__init__("daily budget file is locked (chattr +i)")
@dataclass(frozen=True)
class Biometrics:
"""Body metrics that feed the Mifflin-St Jeor budget formula.
Grouped into one value object so the budget calculation stays under the
repo's five-argument lint ceiling and so the inputs travel together.
Attributes:
weight_kg: Body mass in kilograms.
height_cm: Height in centimetres.
age_years: Age in years.
is_male: True for the male BMR constant (+5), False for female (-161).
"""
weight_kg: float
height_cm: float
age_years: float
is_male: bool
def mifflin_st_jeor_bmr(bio: Biometrics) -> float:
"""Return resting metabolic rate via the Mifflin-St Jeor equation.
Args:
bio: The person's body metrics.
Returns:
Basal metabolic rate in kcal/day.
"""
base = 10.0 * bio.weight_kg + 6.25 * bio.height_cm - 5.0 * bio.age_years
return base + 5.0 if bio.is_male else base - 161.0
def compute_target_budget(
bio: Biometrics,
*,
activity_factor: float,
deficit_kcal: float,
) -> int:
"""Return the daily kcal target: TDEE minus a deficit, floored for safety.
TDEE (total daily energy expenditure) is the BMR scaled by an activity
factor; subtracting a deficit yields a target that drives gradual loss.
Args:
bio: The person's body metrics.
activity_factor: Multiplier for daily activity (e.g. 1.2 sedentary,
1.375 light, 1.55 moderate, 1.725 very active).
deficit_kcal: Calories subtracted from TDEE for weight loss.
Returns:
The target budget in kcal, never below ``_MIN_SANE_BUDGET``.
"""
bmr = mifflin_st_jeor_bmr(bio)
tdee = bmr * activity_factor
target = round(tdee - deficit_kcal)
return max(target, _MIN_SANE_BUDGET)
def _hmac_key_available() -> bool:
"""Return True if the shared HMAC key can be loaded for signing."""
return compute_entry_hmac(_KEY_PROBE) is not None
def is_initialized() -> bool:
"""Return True if a sealed budget file exists on disk."""
return BUDGET_FILE.exists()
def lock_command() -> str:
"""Return the shell command that makes the sealed budget immutable."""
return f"sudo chattr +i {BUDGET_FILE}"
def unlock_command() -> str:
"""Return the shell command that clears the immutable bit before re-init."""
return f"sudo chattr -i {BUDGET_FILE}"
def seal_budget(value: int, *, weight_kg: float | None = None) -> None:
"""Write ``value`` to the runtime budget file, base64-wrapped and signed.
The value is JSON-encoded, base64-wrapped (so a casual ``cat`` shows no
recognizable number) and HMAC-signed (so a naive edit is detectable). The
file is *not* made immutable here -- that needs root; the caller prints
:func:`lock_command` for the user (or install.sh) to run.
Args:
value: The computed daily budget in kcal.
weight_kg: Body weight in kg to store alongside the budget, so a protein
target can later be derived. Optional; omitting it seals a
budget-only blob that reads back with no protein target.
Raises:
BudgetLockedError: If the existing file is immutable (run
:func:`unlock_command` first).
"""
inner: dict[str, object] = {"v": _SEAL_VERSION, "b": int(value)}
if weight_kg is not None:
inner["w"] = round(float(weight_kg), 1)
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
record: dict[str, object] = {"data": base64.b64encode(blob).decode("ascii")}
signature = compute_entry_hmac(inner)
if signature is not None:
record["hmac"] = signature
else:
_logger.warning("HMAC key unavailable - sealing budget unsigned")
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
try:
with BUDGET_FILE.open("w") as handle:
json.dump(record, handle)
except PermissionError as exc:
raise BudgetLockedError from exc
def _decode_inner(record: object) -> dict[str, object]:
"""Return the inner payload dict from a parsed seal record.
Raises:
BudgetSealBrokenError: If the record shape or base64 is invalid.
"""
if not isinstance(record, dict):
raise BudgetSealBrokenError
data = record.get("data")
if not isinstance(data, str):
raise BudgetSealBrokenError
try:
inner = json.loads(base64.b64decode(data, validate=True))
except (binascii.Error, ValueError) as exc:
raise BudgetSealBrokenError from exc
if not isinstance(inner, dict):
raise BudgetSealBrokenError
return inner
def _verify_signature(record: dict[str, object], inner: dict[str, object]) -> None:
"""Check the seal's HMAC, mirroring the food log's degradation rules.
A present signature must verify. A missing signature is tolerated only on a
system with no key at all; a stripped signature where a key *is* available
means someone removed it to cheat.
Raises:
BudgetSealBrokenError: If the signature is missing-but-keyed, or wrong.
"""
stored = record.get("hmac")
if isinstance(stored, str):
expected = compute_entry_hmac(inner)
if expected is None or not hmac.compare_digest(stored, expected):
raise BudgetSealBrokenError
return
if _hmac_key_available():
raise BudgetSealBrokenError
def _load_verified_inner() -> dict[str, object]:
"""Read, decode, and integrity-check the sealed blob, returning its payload.
Returns:
The inner payload dict (carrying ``b`` and, for v2 seals, ``w``).
Raises:
BudgetNotInitializedError: If no budget has been sealed yet.
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
"""
if not BUDGET_FILE.exists():
raise BudgetNotInitializedError
try:
with BUDGET_FILE.open() as handle:
record = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
raise BudgetSealBrokenError from exc
inner = _decode_inner(record)
# ``record`` is a dict here: _decode_inner rejects any non-dict already.
_verify_signature(record, inner)
return inner
def daily_budget() -> int:
"""Return the sealed daily budget, verifying integrity first.
This is the only way callers obtain the number, and they must use it for an
internal decision (over/under) without printing it.
Returns:
The daily kcal budget.
Raises:
BudgetNotInitializedError: If no budget has been sealed yet.
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
"""
inner = _load_verified_inner()
value = inner.get("b")
if isinstance(value, bool) or not isinstance(value, int):
raise BudgetSealBrokenError
return value
def budget_weight() -> float | None:
"""Return the body weight stored with the budget, or None if unavailable.
Returns:
The stored weight in kg, or None for a pre-v2 (budget-only) seal.
Raises:
BudgetNotInitializedError: If no budget has been sealed yet.
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
"""
inner = _load_verified_inner()
value = inner.get("w")
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def protein_target_g() -> float | None:
"""Return the daily protein target in grams, or None if it cannot be derived.
Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns
None -- rather than raising -- whenever the target is simply unavailable (no
budget sealed, a pre-v2 seal without weight, or a broken seal), so the
dashboard can show calories and quietly omit the protein line.
Returns:
The protein target in grams, or None when weight is unknown.
"""
try:
weight = budget_weight()
except BudgetError:
return None
if weight is None:
return None
return round(weight * PROTEIN_G_PER_KG, 1)

498
diet_guard/_cli.py Normal file
View File

@ -0,0 +1,498 @@
"""Command-line interface for diet_guard.
Examples:
python -m python_pkg.diet_guard init
python -m python_pkg.diet_guard ate "big mac"
python -m python_pkg.diet_guard ate "two slices of pizza" --grams 240
python -m python_pkg.diet_guard ate "protein shake" --kcal 180
python -m python_pkg.diet_guard status
python -m python_pkg.diet_guard undo
The daily budget lives outside the repo (so it is never exposed online) but is
shown freely on this machine: ``status`` and each log print how many calories
are left of the day's budget, plus which meal slots still need logging.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import sys
from python_pkg.diet_guard._budget import (
Biometrics,
BudgetLockedError,
BudgetNotInitializedError,
BudgetSealBrokenError,
compute_target_budget,
daily_budget,
lock_command,
protein_target_g,
seal_budget,
unlock_command,
)
from python_pkg.diet_guard._foodbank import remember_food
from python_pkg.diet_guard._gate import due_slots, gate_is_due
from python_pkg.diet_guard._gatelock import (
MealGate,
acquire_gate_lock,
release_gate_lock,
wait_for_display,
)
from python_pkg.diet_guard._portions import (
DEFAULT_ITEM_GRAMS,
estimate_unit_grams,
)
from python_pkg.diet_guard._resolve import ManualMacros, resolve_nutrition
from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label
from python_pkg.diet_guard._state import (
entry_kcal,
log_meal,
logged_slots_today,
now_local,
today_entries,
today_total_kcal,
today_total_macros,
undo_last_today,
)
# Column width for a meal description in the status listing.
_DESC_WIDTH = 24
# An ISO timestamp formats as "YYYY-MM-DDTHH:MM:SS"; HH:MM is chars 11..16.
_TIME_SLICE = slice(11, 16)
# Accepted answers for the sex prompt that map to the male BMR constant.
_MALE_ANSWERS = {"m", "male"}
_FEMALE_ANSWERS = {"f", "female"}
@dataclass(frozen=True)
class _ManualMacros:
"""User-supplied calories/macros for ``ate``, all optional.
Grouping these keeps :func:`_cmd_ate` within the argument-count limit and
makes "manual values were supplied" a single, testable value object.
Attributes:
kcal: Calories entered manually (None means look the food up instead).
protein: Protein grams, recorded alongside ``kcal``.
carbs: Carbohydrate grams, recorded alongside ``kcal``.
fat: Fat grams, recorded alongside ``kcal``.
"""
kcal: float | None
protein: float | None
carbs: float | None
fat: float | None
@dataclass(frozen=True)
class _Portion:
"""How much was eaten and the basis for any typed macros.
Grouped so :func:`_cmd_ate` stays within the argument-count limit.
Attributes:
grams: Explicit grams eaten, or None.
count: Number of items eaten (an alternative to ``grams``), or None.
per_grams: Reference weight the typed macros are stated for (e.g. 100
for a per-100 g label), or None to treat the macros as totals.
"""
grams: float | None
count: float | None
per_grams: float | None
def _emit(text: str = "") -> None:
"""Write one line to stdout.
A thin wrapper over ``sys.stdout.write`` so genuine CLI output does not
trip ruff's ``T201`` (no ``print``) without resorting to a suppression.
"""
sys.stdout.write(f"{text}\n")
def _ask(label: str) -> str:
"""Print a prompt label and return one trimmed line from stdin."""
_emit(label)
return sys.stdin.readline().strip()
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse diet_guard CLI arguments."""
parser = argparse.ArgumentParser(
prog="diet_guard",
description="Log calories and check your daily budget.",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser(
"init",
help="Compute your daily budget from biometrics and seal it (hidden).",
)
ate = sub.add_parser("ate", help="Log a meal you just ate.")
ate.add_argument("description", help='What you ate, e.g. "big mac".')
ate.add_argument(
"--grams",
type=float,
default=None,
help="Portion size in grams (default: OFF serving size, else 100 g).",
)
ate.add_argument(
"--kcal",
type=float,
default=None,
help="Calories entered manually; skips the food bank and OFF lookup.",
)
ate.add_argument(
"--protein",
type=float,
default=None,
help="Protein in grams (recorded with --kcal to seed the food bank).",
)
ate.add_argument(
"--carbs",
type=float,
default=None,
help="Carbohydrate in grams (recorded with --kcal).",
)
ate.add_argument(
"--fat",
type=float,
default=None,
help="Fat in grams (recorded with --kcal).",
)
ate.add_argument(
"--per",
type=float,
default=None,
help="Grams the macros are stated for (e.g. 100 for a per-100 g label);"
" the typed macros are scaled from this to how much you ate.",
)
ate.add_argument(
"--count",
type=float,
default=None,
help="Number of items eaten (e.g. 5 apples) instead of --grams;"
" multiplied by the staple's unit weight.",
)
sub.add_parser("status", help="Show today's calories and budget band.")
sub.add_parser("undo", help="Remove today's most recent entry.")
gate = sub.add_parser(
"gate",
help="Log-to-unlock screen gate (intended to be run by a timer).",
)
gate.add_argument(
"--check",
action="store_true",
help="Headless: exit 0 if NOT due, 1 if a lock is due. Prints, no window.",
)
gate.add_argument(
"--demo",
action="store_true",
help="Show the lock in safe demo mode (local grab + close button).",
)
return parser.parse_args(argv)
def _print_summary() -> None:
"""Print today's total and how much of the daily budget is left.
The budget number is shown here on purpose: it is "hidden" only in the
sense of never leaving this machine (it lives outside the repo), not hidden
from the user, who needs it to make portion decisions.
"""
total = today_total_kcal()
try:
budget = daily_budget()
except BudgetNotInitializedError:
_emit(
f"today: {total:g} kcal "
"(budget not set - run: python -m python_pkg.diet_guard init)",
)
return
except BudgetSealBrokenError:
_emit(f"today: {total:g} kcal (budget seal broken - re-run init)")
return
remaining = round(budget - total, 1)
_emit(f"today: {total:g} kcal - {remaining:g} kcal left of {budget:g}")
def _print_entry_line(entry: dict[str, object]) -> None:
"""Print a single log entry as 'HH:MM desc kcal (source)'."""
time_str = str(entry.get("time", ""))[_TIME_SLICE]
desc = str(entry.get("desc", "?"))
source = str(entry.get("source", ""))
_emit(
f" {time_str:>5} {desc:<{_DESC_WIDTH}.{_DESC_WIDTH}} "
f"{entry_kcal(entry):>6.0f} kcal ({source})",
)
def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
"""Prompt for biometrics on stdin; return (bio, activity, deficit) or None.
Returns None (after printing why) on any unparsable or out-of-range input,
so a typo never seals a wrong budget.
"""
try:
weight = float(_ask("weight in kg:"))
height = float(_ask("height in cm:"))
age = float(_ask("age in years:"))
sex_raw = _ask("sex (m/f):").lower()
activity = float(
_ask(
"activity factor "
"(1.2 sedentary / 1.375 light / 1.55 moderate / 1.725 active):",
),
)
deficit = float(_ask("daily deficit in kcal (e.g. 200):"))
except ValueError:
_emit("that was not a number; nothing was sealed.")
return None
if sex_raw in _MALE_ANSWERS:
is_male = True
elif sex_raw in _FEMALE_ANSWERS:
is_male = False
else:
_emit('sex must be "m" or "f"; nothing was sealed.')
return None
bio = Biometrics(
weight_kg=weight,
height_cm=height,
age_years=age,
is_male=is_male,
)
return bio, activity, deficit
def _cmd_init() -> int:
"""Compute the budget from biometrics and seal it, printing no number."""
inputs = _read_init_inputs()
if inputs is None:
return 2
bio, activity, deficit = inputs
budget = compute_target_budget(
bio,
activity_factor=activity,
deficit_kcal=deficit,
)
try:
seal_budget(budget, weight_kg=bio.weight_kg)
except BudgetLockedError:
_emit("the budget is locked; unlock it first, then re-run init:")
_emit(f" {unlock_command()}")
return 1
_emit("budget computed from your biometrics and sealed - the number is")
_emit("intentionally not shown.")
_emit(f"to lock it against casual edits, run: {lock_command()}")
return 0
def _eaten_grams(
description: str,
portion: _Portion,
) -> tuple[float | None, str | None]:
"""Resolve how many grams were eaten, plus a note if a weight was assumed.
A count of items is turned into grams via the staple's unit weight; an
unknown item falls back to a default weight, with a note so the estimate is
never silent.
Args:
description: The food name (used to look up a per-item weight).
portion: The user's portion inputs.
Returns:
``(grams, note)`` where ``grams`` may be None (no portion given) and
``note`` is a one-line caveat to print, or None.
"""
if portion.count is not None:
unit = estimate_unit_grams(description)
if unit is None:
return (
portion.count * DEFAULT_ITEM_GRAMS,
f"(assumed {DEFAULT_ITEM_GRAMS:g} g per item; "
"pass --grams to be exact)",
)
return portion.count * unit, None
return portion.grams, None
def _cmd_ate(description: str, portion: _Portion, macros: _ManualMacros) -> int:
"""Resolve and log a meal, tag its slot, bank it, then print the total.
Resolution order is manual, then food bank, then the staple table, then
Open Food Facts (see :func:`resolve_nutrition`). A per-item count or a
per-reference macro basis is converted to the amount actually eaten first,
and the food is remembered so next time it is served from local history.
"""
eaten, note = _eaten_grams(description, portion)
if note is not None:
_emit(note)
manual_macros = (
ManualMacros(
kcal=macros.kcal,
protein=macros.protein or 0.0,
carbs=macros.carbs or 0.0,
fat=macros.fat or 0.0,
per_grams=portion.per_grams,
)
if macros.kcal is not None
else None
)
nutrition = resolve_nutrition(
description,
grams=eaten,
manual_macros=manual_macros,
)
if nutrition is None:
_emit(
f'no food bank, staple, or Open Food Facts match for "{description}". '
"re-run with --kcal <number> to log it manually.",
)
return 1
log_meal(description, nutrition, current_slot(now_local()))
remember_food(description, nutrition)
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"
_emit(
f"logged: {description} {nutrition.kcal:g} kcal "
f"({macro_str}) [{nutrition.source}, {portion_str}]",
)
_print_summary()
return 0
def _print_slot_status() -> None:
"""Print each meal slot as logged / DUE / upcoming for today."""
logged = logged_slots_today()
due = set(due_slots())
parts: list[str] = []
for slot in day_slots():
if slot in logged:
mark = "logged"
elif slot in due:
mark = "DUE"
else:
mark = "upcoming"
parts.append(f"{slot_label(slot)} {mark}")
_emit("slots: " + " ".join(parts))
def _print_macro_status() -> None:
"""Print today's macros so far, with the protein target when it is known.
Mirrors the gate's dashboard on the command line so "how am I doing" is
answerable without opening the window. The protein target only appears once
the budget has been initialized with a body weight (see ``init``).
"""
protein, carbs, fat = today_total_macros()
line = f"macros: P{protein:g} C{carbs:g} F{fat:g} g"
target = protein_target_g()
if target is not None:
remaining = round(target - protein, 1)
line += f" - protein {protein:g}/{target:g} g ({remaining:g} left)"
_emit(line)
def _cmd_status() -> int:
"""Print today's entries, per-slot status, macros, and the budget remaining."""
entries = today_entries()
for entry in entries:
_print_entry_line(entry)
if entries:
_emit("-" * 48)
_print_slot_status()
_print_summary()
_print_macro_status()
return 0
def _cmd_undo() -> int:
"""Remove today's most recent entry and report what was removed."""
removed = undo_last_today()
if removed is None:
_emit("nothing to undo today.")
return 0
desc = str(removed.get("desc", "?"))
_emit(f"removed: {desc} ({entry_kcal(removed):g} kcal)")
_print_summary()
return 0
def _cmd_gate(*, check: bool, demo: bool) -> int:
"""Run the log-to-unlock gate.
Three modes: ``--check`` is a headless decision (no window) whose exit code
a timer reads; ``--demo`` always shows a safe demo window; bare ``gate``
shows the real lock only when one is due. A flock guard stops a second
window from stacking on top of the first, and a window-opening mode first
waits for the X display so a session-start launch never crashes unshown.
Args:
check: Headless mode -- print and return an exit code, open no window.
demo: Use safe demo mode (local grab + close button) for the window.
Returns:
For ``--check``: 0 if not due, 1 if a lock is due. Otherwise 0.
"""
if check:
due = gate_is_due()
_emit("due (a lock is warranted)" if due else "ok (no lock needed)")
return 1 if due else 0
if not demo and not gate_is_due():
_emit("ok - no lock needed right now.")
return 0
handle = acquire_gate_lock()
if handle is None:
_emit("the gate is already running.")
return 0
try:
# At session start the timer can fire before the X display/auth cookie
# is ready; wait it out so the window opens instead of crashing on a
# "couldn't connect to display" TclError (see _gatelock.wait_for_display).
if not wait_for_display():
_emit("display not ready yet; will retry on the next timer tick.")
return 0
MealGate(demo_mode=demo).run()
finally:
release_gate_lock(handle)
return 0
def main(argv: list[str] | None = None) -> int:
"""Dispatch a diet_guard subcommand.
Args:
argv: Argument list (defaults to ``sys.argv[1:]``).
Returns:
A process exit code (0 on success).
"""
args = _parse_args(sys.argv[1:] if argv is None else argv)
if args.command == "init":
return _cmd_init()
if args.command == "ate":
macros = _ManualMacros(
kcal=args.kcal,
protein=args.protein,
carbs=args.carbs,
fat=args.fat,
)
portion = _Portion(
grams=args.grams,
count=args.count,
per_grams=args.per,
)
return _cmd_ate(args.description, portion, macros)
if args.command == "status":
return _cmd_status()
if args.command == "gate":
return _cmd_gate(check=args.check, demo=args.demo)
return _cmd_undo()

65
diet_guard/_constants.py Normal file
View File

@ -0,0 +1,65 @@
"""Constants for the diet_guard calorie tracker and gate."""
from __future__ import annotations
from pathlib import Path
# --- Daily target -----------------------------------------------------------
# There is deliberately NO budget number here. Like the home GPS coordinates in
# phone_focus_mode (which live only in the git-ignored config_secrets.sh on the
# device, never in committed source), the real budget is computed once from
# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via
# python_pkg.diet_guard._budget.daily_budget() for over/under decisions only and
# is never printed -- see _budget.py for the full threat model.
#
# Fraction of the budget at which status flips from "on track" to "approaching
# limit". Surfaced as a label, so the threshold leaks only by boundary-probing.
BUDGET_WARN_FRACTION: float = 0.80
# --- Storage ----------------------------------------------------------------
# The food log is personal and high-churn, so it lives in the XDG data dir and
# is deliberately NOT committed to git (unlike wake_state.json).
DATA_DIR: Path = Path.home() / ".local" / "share" / "diet_guard"
FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json"
# The user's personal "food bank": every food they have logged before, with its
# full macros, keyed by name. This is the ONLY corpus the gate's autocomplete
# searches -- Open Food Facts is used to *fill* a new food's macros, never to
# search. Local-only, git-ignored.
FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json"
# The sealed budget: a dotfile alongside the log, base64-wrapped + HMAC-signed,
# made immutable with ``chattr +i``. Git-ignored, never committed. "Hidden"
# here means never-online (it lives outside the repo) -- the number is still
# shown freely in local CLI/GUI output; the seal only makes *cheating* hard.
BUDGET_FILE: Path = DATA_DIR / ".budget"
# --- Estimator (Open Food Facts) -------------------------------------------
# The default backend is Open Food Facts' "Search-a-licious" full-text search:
# free, no key, strongest for branded/packaged foods (including fast food).
# (The older cgi/search.pl endpoint is heavily rate-limited and returns an HTML
# "temporarily unavailable" page to API clients, and /api/v2/search ignores the
# query term, so neither is usable here.) Swappable for a local/remote LLM
# backend later without touching the log or CLI layers.
OFF_SEARCH_URL: str = "https://search.openfoodfacts.org/search"
OFF_TIMEOUT_SECONDS: float = 8.0
OFF_PAGE_SIZE: int = 5
# Open Food Facts asks API clients to identify themselves with a descriptive
# User-Agent string so abusive clients can be told apart from polite ones.
OFF_USER_AGENT: str = "diet_guard/1.0 (personal diet tracker)"
# Portion assumed when neither --grams nor an OFF serving size is available.
DEFAULT_PORTION_GRAMS: float = 100.0
# --- Gate (log-to-unlock) ---------------------------------------------------
# The gate is driven by FIXED MEAL SLOTS, not by a gap timer. Starting at the
# day-start hour, a slot opens every interval; once a slot's hour has passed,
# that slot must carry a logged meal or the screen locks until it does. This
# makes tracking fully automatic (you are prompted on a schedule rather than
# trusted to log voluntarily) and nudges regular eating. Coming home late
# naturally produces several unlogged elapsed slots at once -> one lock that
# backfills the whole day, which is the "requirement to access the PC" behavior.
GATE_DAY_START_HOUR: int = 8 # first slot (08:00); also the "beginning of day"
GATE_SLOT_INTERVAL_HOURS: int = 4 # slots at 08:00, 12:00, 16:00, 20:00
# Past this hour the gate never fires, so an unlogged late slot lapses quietly
# instead of locking you out overnight. (A new day resets all slots at 00:00.)
GATE_EATING_END_HOUR: int = 22 # exclusive (22:00)
# flock single-instance guard: stops a timer from stacking lock windows.
GATE_LOCK_FILE: Path = DATA_DIR / ".gate.lock"

325
diet_guard/_estimator.py Normal file
View File

@ -0,0 +1,325 @@
"""Calorie/macro estimation backends for diet_guard.
The default backend queries the public Open Food Facts (OFF) database over
HTTP -- no API key required. It is strongest for branded/packaged foods
(fast food included, which is the binge target) and weaker for generic
home-cooked descriptions; in the latter case the caller should fall back to a
manual ``--kcal`` value.
The backend is intentionally small and pluggable: replace :func:`estimate`
with a local-LLM (ollama) or remote-LLM implementation later without touching
the log/state or CLI layers.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
import logging
import requests
from python_pkg.diet_guard._constants import (
DEFAULT_PORTION_GRAMS,
OFF_PAGE_SIZE,
OFF_SEARCH_URL,
OFF_TIMEOUT_SECONDS,
OFF_USER_AGENT,
)
_logger = logging.getLogger(__name__)
# Open Food Facts nutriment field names (values are "per 100 g").
_OFF_KCAL_FIELD = "energy-kcal_100g"
_OFF_PROTEIN_FIELD = "proteins_100g"
_OFF_CARBS_FIELD = "carbohydrates_100g"
_OFF_FAT_FIELD = "fat_100g"
_GRAMS_PER_REFERENCE = 100.0
@dataclass(frozen=True)
class Nutrition:
"""Estimated nutrition for one logged portion of food.
Attributes:
kcal: Total energy for the portion, in kilocalories.
protein_g: Protein for the portion, in grams.
carbs_g: Carbohydrate for the portion, in grams.
fat_g: Fat for the portion, in grams.
grams: Portion size used for the estimate, in grams (0 if unknown).
source: Human-readable provenance, e.g. ``"openfoodfacts: Big Mac"``
or ``"manual"``.
"""
kcal: float
protein_g: float
carbs_g: float
fat_g: float
grams: float
source: str
def _as_float(value: object) -> float | None:
"""Coerce an Open Food Facts numeric field to ``float``.
OFF returns numbers as ints, floats, or numeric strings depending on the
product, so accept all three. ``bool`` is rejected even though it is an
``int`` subtype, since a boolean nutriment value is meaningless.
Args:
value: The raw field value.
Returns:
The value as a float, or None if it is not numeric.
"""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return None
return None
def manual(
kcal: float,
grams: float | None = None,
*,
protein_g: float = 0.0,
carbs_g: float = 0.0,
fat_g: float = 0.0,
) -> Nutrition:
"""Build a :class:`Nutrition` from user-supplied values.
Calories are required; the three macros are optional so the offline path
stays low-friction (a bare ``--kcal`` always works) while a user who knows
the full breakdown can record it and seed the food bank with it.
Args:
kcal: Calories the user entered directly.
grams: Optional portion size, kept only for display.
protein_g: Protein in grams (0 if unknown).
carbs_g: Carbohydrate in grams (0 if unknown).
fat_g: Fat in grams (0 if unknown).
Returns:
A Nutrition with the supplied macros and ``source="manual"``.
"""
return Nutrition(
kcal=round(float(kcal), 1),
protein_g=round(float(protein_g), 1),
carbs_g=round(float(carbs_g), 1),
fat_g=round(float(fat_g), 1),
grams=round(float(grams), 1) if grams is not None else 0.0,
source="manual",
)
def scale_nutrition(nutrition: Nutrition, grams: float) -> Nutrition:
"""Rescale a portion's macros to a new weight in grams (pure).
A banked or looked-up food stores the macros for *some* portion; eating a
different amount must scale every macro proportionally, so 200 g of a food
banked at 100 g logs double the calories. When the basis portion is unknown
(``grams == 0``) there is nothing to scale from, so the macros are kept and
only the recorded weight is updated -- best effort rather than a wrong
number.
Args:
nutrition: The basis nutrition (its ``grams`` is the basis weight).
grams: The new portion weight in grams.
Returns:
A new Nutrition scaled to ``grams`` (source preserved).
"""
if nutrition.grams <= 0 or grams <= 0:
return replace(nutrition, grams=grams if grams > 0 else nutrition.grams)
factor = grams / nutrition.grams
return replace(
nutrition,
kcal=round(nutrition.kcal * factor, 1),
protein_g=round(nutrition.protein_g * factor, 1),
carbs_g=round(nutrition.carbs_g * factor, 1),
fat_g=round(nutrition.fat_g * factor, 1),
grams=round(grams, 1),
)
def _off_search(term: str) -> list[dict[str, object]]:
"""Query Open Food Facts for products matching ``term``.
Args:
term: Free-text food description.
Returns:
A list of product dicts (possibly empty), most relevant first.
Raises:
requests.RequestException: On any network or HTTP failure.
"""
params = {
"q": term,
"fields": "product_name,nutriments,serving_quantity",
"page_size": str(OFF_PAGE_SIZE),
}
response = requests.get(
OFF_SEARCH_URL,
params=params,
headers={"User-Agent": OFF_USER_AGENT},
timeout=OFF_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
return []
# Search-a-licious returns matches under "hits" (ranked by relevance).
hits = payload.get("hits", [])
if not isinstance(hits, list):
return []
return [hit for hit in hits if isinstance(hit, dict)]
def _products_with_energy(
products: list[dict[str, object]],
) -> list[tuple[dict[str, object], dict[str, object]]]:
"""Return the products that carry a usable kcal/100 g value, in order.
Args:
products: Product dicts from :func:`_off_search`.
Returns:
``(product, nutriments)`` tuples for every product with a kcal value,
preserving Open Food Facts' relevance order.
"""
matches: list[tuple[dict[str, object], dict[str, object]]] = []
for product in products:
nutriments = product.get("nutriments")
if not isinstance(nutriments, dict):
continue
if _as_float(nutriments.get(_OFF_KCAL_FIELD)) is not None:
matches.append((product, nutriments))
return matches
def _resolve_portion(grams: float | None, product: dict[str, object]) -> float:
"""Decide the portion size, in grams, to use for an estimate.
Priority: an explicit ``grams`` argument, then the product's Open Food
Facts serving size, then the configured default. Keeping ``--grams``
optional is deliberate: per-entry friction is the whole reason food diaries
get abandoned, so ``ate "big mac"`` must just work.
Args:
grams: Caller-supplied portion, or None.
product: OFF product dict (may carry ``serving_quantity``).
Returns:
A portion size in grams, always greater than zero.
"""
if grams is not None and grams > 0:
return float(grams)
serving = _as_float(product.get("serving_quantity"))
if serving is not None and serving > 0:
return serving
return DEFAULT_PORTION_GRAMS
def _off_nutrition(
product: dict[str, object],
nutriments: dict[str, object],
grams: float | None,
description: str,
) -> Nutrition:
"""Build a Nutrition for one OFF product, scaled to the chosen portion."""
portion = _resolve_portion(grams, product)
factor = portion / _GRAMS_PER_REFERENCE
name = product.get("product_name")
label = name if isinstance(name, str) and name.strip() else description
return Nutrition(
kcal=round(_scaled(nutriments, _OFF_KCAL_FIELD, factor), 1),
protein_g=round(_scaled(nutriments, _OFF_PROTEIN_FIELD, factor), 1),
carbs_g=round(_scaled(nutriments, _OFF_CARBS_FIELD, factor), 1),
fat_g=round(_scaled(nutriments, _OFF_FAT_FIELD, factor), 1),
grams=round(portion, 1),
source=f"openfoodfacts: {label}",
)
def off_candidates(
description: str,
grams: float | None = None,
limit: int = OFF_PAGE_SIZE,
) -> list[Nutrition]:
"""Return up to ``limit`` Open Food Facts matches for ``description``.
Returning several candidates (rather than only the top hit) lets the gate
show alternatives so the user can pick the product that actually matches
what they ate, instead of silently accepting the first guess.
Args:
description: Free-text food description (e.g. ``"big mac"``).
grams: Portion size in grams; serving size or the default is used when
None.
limit: Maximum number of candidates to return.
Returns:
Nutrition estimates in OFF relevance order (empty if OFF is unreachable
or has no usable match).
"""
try:
products = _off_search(description)
except requests.RequestException as exc:
_logger.warning("Open Food Facts request failed: %s", exc)
return []
return [
_off_nutrition(product, nutriments, grams, description)
for product, nutriments in _products_with_energy(products)[:limit]
]
def estimate_off(description: str, grams: float | None) -> Nutrition | None:
"""Estimate nutrition for ``description`` via Open Food Facts (top match).
Args:
description: Free-text food description (e.g. ``"big mac"``).
grams: Portion size in grams. When None, the product's serving size
is used if known, otherwise the configured default portion.
Returns:
The best Nutrition estimate, or None if OFF is unreachable or has no
usable match (the caller should then fall back to a manual value).
"""
candidates = off_candidates(description, grams, limit=1)
return candidates[0] if candidates else None
def _scaled(nutriments: dict[str, object], field: str, factor: float) -> float:
"""Return a per-100 g nutriment scaled to the portion (0 if missing)."""
per_reference = _as_float(nutriments.get(field))
if per_reference is None:
return 0.0
return per_reference * factor
def estimate(
description: str,
*,
grams: float | None = None,
manual_kcal: float | None = None,
) -> Nutrition | None:
"""Estimate nutrition for a meal; a manual value takes precedence.
Args:
description: Free-text food description.
grams: Optional portion size in grams.
manual_kcal: If given, used directly and Open Food Facts is skipped.
Returns:
A Nutrition estimate, or None when no manual value was supplied and OFF
could not produce a usable match.
"""
if manual_kcal is not None:
return manual(manual_kcal, grams)
return estimate_off(description, grams)

281
diet_guard/_foodbank.py Normal file
View File

@ -0,0 +1,281 @@
"""The user's personal food bank: a local corpus of previously logged foods.
Every food the user logs is remembered here with its full macros, keyed by a
normalized name. The gate's autocomplete searches *only* this corpus -- never
Open Food Facts. OFF (in :mod:`python_pkg.diet_guard._estimator`) is used only
to *fill in* the macros of a brand-new food the first time it is entered; from
then on the food is served from the bank, so search quality improves with use
and works fully offline.
Search is intentionally typo-tolerant. Rather than a prefix/exact match, it
combines substring containment with :func:`difflib.SequenceMatcher` similarity
(stdlib -- no extra dependency), so "chiken breast" still finds "chicken
breast". Results are ranked by match quality, then by how often the food has
been logged, so your staples float to the top.
"""
from __future__ import annotations
import json
import logging
import time
from typing import TYPE_CHECKING
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
if TYPE_CHECKING:
from collections.abc import Sequence
_logger = logging.getLogger(__name__)
# Below this similarity ratio a non-substring candidate is not a plausible typo
# of the query and is dropped. SequenceMatcher's own "close match" default is
# 0.6; we reuse it so behavior matches difflib intuitions.
_FUZZY_THRESHOLD = 0.6
# Default number of autocomplete suggestions to surface.
DEFAULT_SUGGESTIONS = 8
# On-disk shape: {normalized_name: {"desc", "kcal", "protein_g", "carbs_g",
# "fat_g", "grams", "count"}}. ``count`` ranks frequently eaten staples first.
BankRecord = dict[str, object]
def _normalize(description: str) -> str:
"""Return the lookup key for a description (trimmed, case-folded)."""
return description.strip().casefold()
def _read_bank() -> dict[str, BankRecord]:
"""Read the food bank from disk (empty dict on any error).
A corrupt or unreadable file is moved aside (see
:func:`_quarantine_corrupt_bank`) rather than re-warned about on every call:
the gate reads the bank on each keystroke, so a single bad file would
otherwise flood the journal and then be silently overwritten by the next
write.
"""
if not FOOD_BANK_FILE.exists():
return {}
try:
with FOOD_BANK_FILE.open() as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
_quarantine_corrupt_bank()
return {}
if not isinstance(data, dict):
return {}
return {
key: value
for key, value in data.items()
if isinstance(key, str) and isinstance(value, dict)
}
def _quarantine_corrupt_bank() -> None:
"""Move an unreadable bank aside to a timestamped backup, warning once.
Renaming the bad file means the next read finds nothing and returns an empty
bank quietly (no per-keystroke warning flood), the next write starts a fresh
bank, and the original is preserved for manual recovery instead of being
silently overwritten and lost.
"""
backup = FOOD_BANK_FILE.with_name(
f"{FOOD_BANK_FILE.name}.corrupt-{int(time.time())}",
)
try:
FOOD_BANK_FILE.rename(backup)
except OSError:
_logger.warning(
"Food bank %s is unreadable and cannot be moved", FOOD_BANK_FILE
)
return
_logger.warning(
"Food bank %s was unreadable; moved aside to %s and starting fresh",
FOOD_BANK_FILE,
backup,
)
def _write_bank(bank: dict[str, BankRecord]) -> None:
"""Persist the food bank to disk, creating the data directory if needed."""
FOOD_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
with FOOD_BANK_FILE.open("w") as handle:
json.dump(bank, handle, indent=2, sort_keys=True)
def _record_to_nutrition(record: BankRecord) -> Nutrition:
"""Build a :class:`Nutrition` from a stored bank record.
Missing or non-numeric fields default to 0.0 so a hand-edited or partial
record can never raise while the user is mid-log.
Args:
record: A stored food-bank record.
Returns:
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")),
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.
The latest macros win, so correcting a food's calories once fixes every
future suggestion. A blank description is ignored.
Args:
description: The user's free-text food name.
nutrition: The macros to store for it.
"""
_upsert(description, nutrition, components=None)
def remember_meal(name: str, items: Sequence[MealItem]) -> Nutrition:
"""Bank each component and the composite meal, returning the summed macros.
Each item is remembered on its own (so it autocompletes next time) and the
meal is stored as one entry carrying its summed macros plus its component
names, so the whole meal can be re-picked later as a single summed food. A
blank meal name still banks the items but stores no empty-keyed composite.
Args:
name: The composite meal's name (e.g. ``"dinner"``).
items: The meal's components, each with its own nutrition.
Returns:
The summed nutrition for the whole meal.
"""
for item in items:
remember_food(item.name, item.nutrition)
total = meal_total(items)
_upsert(name, total, components=[item.name for item in items])
return total
def _upsert(
description: str,
nutrition: Nutrition,
*,
components: list[str] | None,
) -> None:
"""Insert or refresh one bank record, bumping its use count.
Shared by :func:`remember_food` (a single food) and :func:`remember_meal`
(a composite, which additionally records its ``components``). A blank
description is ignored, so an unnamed entry is never stored.
Args:
description: The food or meal name (its normalized form is the key).
nutrition: The macros to store.
components: Component names for a composite meal, or None for a food.
"""
key = _normalize(description)
if not key:
return
bank = _read_bank()
previous = bank.get(key, {})
count = _as_float(previous.get("count")) + 1
record: BankRecord = {
"desc": description.strip(),
"kcal": nutrition.kcal,
"protein_g": nutrition.protein_g,
"carbs_g": nutrition.carbs_g,
"fat_g": nutrition.fat_g,
"grams": nutrition.grams,
"count": count,
}
if components is not None:
record["components"] = list(components)
bank[key] = record
_write_bank(bank)
def lookup_food(description: str) -> Nutrition | None:
"""Return the exact-match macros for ``description``, or None.
Args:
description: The food name to look up verbatim (case-insensitive).
Returns:
The stored Nutrition, or None if the food is not banked.
"""
record = _read_bank().get(_normalize(description))
return _record_to_nutrition(record) if record is not None else None
def _display_name(record: BankRecord, key: str) -> str:
"""Return a record's display name, falling back to its key."""
desc = record.get("desc")
return desc if isinstance(desc, str) and desc.strip() else key
def search_foods(
query: str,
limit: int = DEFAULT_SUGGESTIONS,
) -> list[tuple[str, Nutrition]]:
"""Return banked foods matching ``query``, best match first.
An empty query returns the most-logged foods (the expandable full list).
A non-empty query keeps substring and close-typo matches, ranked by match
quality then by use count.
Args:
query: Free-text the user has typed so far.
limit: Maximum number of suggestions to return.
Returns:
``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long.
"""
bank = _read_bank()
normalized = _normalize(query)
if not normalized:
return _ranked_all(bank, limit)
scored: list[tuple[float, float, str, Nutrition]] = []
for key, record in bank.items():
score = match_score(normalized, key)
if score < _FUZZY_THRESHOLD:
continue
count = _as_float(record.get("count"))
scored.append(
(score, count, _display_name(record, key), _record_to_nutrition(record)),
)
# Sort by score then frequency, both descending.
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
return [(name, nutrition) for _, _, name, nutrition in scored[:limit]]
def _ranked_all(
bank: dict[str, BankRecord],
limit: int,
) -> list[tuple[str, Nutrition]]:
"""Return all banked foods ranked by use count, most-logged first."""
ranked = sorted(
bank.items(),
key=lambda item: _as_float(item[1].get("count")),
reverse=True,
)
return [
(_display_name(record, key), _record_to_nutrition(record))
for key, record in ranked[:limit]
]

63
diet_guard/_fuzzy.py Normal file
View File

@ -0,0 +1,63 @@
"""Shared typo-tolerant string matching for diet_guard.
Two callers need the same similarity logic: the food bank (matching what the
user typed against foods they have logged) and the portions table (matching a
description like "apple" against the known staples). Both depend on the same
key property -- a short typo must still match a long multi-word name -- so the
scoring lives here once rather than being copied.
The trick is to score *word by word* instead of whole-string to whole-string.
"beast" scores near zero against "grilled chicken breast" as a whole (the
length gap dominates) but ~0.91 against the single token "breast"; taking the
best matching token per query word and averaging is what rescues the short
typo. Built on :class:`difflib.SequenceMatcher` (stdlib, no dependency).
"""
from __future__ import annotations
from difflib import SequenceMatcher
def token_score(query: str, name: str) -> float:
"""Score ``query`` against ``name`` word-by-word (length-penalty free).
Each query word is matched against its best word in ``name`` and the
per-word similarities are averaged, so a short typo matches the relevant
word in a long multi-word name instead of being drowned out by length.
Args:
query: The normalized user query.
name: The normalized candidate name.
Returns:
The mean best-per-word similarity in ``[0, 1]``.
"""
query_words = query.split()
name_words = name.split()
if not query_words or not name_words:
return SequenceMatcher(None, query, name).ratio()
total = 0.0
for word in query_words:
total += max(
SequenceMatcher(None, word, target).ratio() for target in name_words
)
return total / len(query_words)
def match_score(query: str, name: str) -> float:
"""Score how well ``name`` matches ``query`` (higher is better).
A substring hit scores at or above 1.0 (boosted by how much of the name the
query covers, so the tightest containing name wins); otherwise fall back to
the token-aware fuzzy score, which tolerates per-word typos.
Args:
query: The normalized user query.
name: The normalized candidate name.
Returns:
A score; substring matches are ``>= 1.0``, fuzzy matches in ``[0, 1)``.
"""
if query and query in name:
return 1.0 + len(query) / len(name)
return token_score(query, name)

67
diet_guard/_gate.py Normal file
View File

@ -0,0 +1,67 @@
"""Decision logic for the diet_guard slot-based log-to-unlock gate.
This module is GUI-free and side-effect-free so the lock/no-lock decision can
be verified headlessly: the fullscreen window in ``_gatelock.py`` is only a
thin shell around :func:`gate_is_due` and :func:`due_slots`. It composes the
pure slot arithmetic in :mod:`python_pkg.diet_guard._slots` with the logged-slot
state in :mod:`python_pkg.diet_guard._state`; ``now`` is injectable so the
time-of-day rules stay deterministically testable.
The gate fires when any *elapsed* meal slot for today carries no logged meal.
Coming home late therefore surfaces several unlogged slots at once -- a single
lock that backfills the whole day before the PC is usable -- while a normal day
prompts one slot at a time, with no separate weekday code path.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from python_pkg.diet_guard._slots import missing_slots, slot_label
from python_pkg.diet_guard._state import logged_slots_today, now_local
if TYPE_CHECKING:
from datetime import datetime
def due_slots(now: datetime | None = None) -> tuple[int, ...]:
"""Return today's elapsed-but-unlogged meal slots, ascending.
Args:
now: Reference time (defaults to the current local time); injectable.
Returns:
The slot hours that still need a meal logged (empty == nothing due).
"""
reference = now if now is not None else now_local()
return missing_slots(reference, logged_slots_today())
def gate_is_due(now: datetime | None = None) -> bool:
"""Return True if the screen should lock until the missing slots are filled.
Args:
now: Reference time (defaults to the current local time); injectable.
Returns:
True if at least one elapsed slot today is unlogged, else False.
"""
return bool(due_slots(now))
def gate_message(now: datetime | None = None) -> str:
"""Return the lock-screen reason line listing the slots to backfill.
Args:
now: Reference time (defaults to the current local time); injectable.
Returns:
A short human-readable explanation of which meals are missing.
"""
missing = due_slots(now)
if not missing:
return "All meals are logged. You're up to date."
labels = ", ".join(slot_label(slot) for slot in missing)
if len(missing) == 1:
return f"Log your {labels} meal to unlock."
return f"Log your meals for {labels} to unlock."

1334
diet_guard/_gatelock.py Normal file

File diff suppressed because it is too large Load Diff

65
diet_guard/_meal.py Normal file
View File

@ -0,0 +1,65 @@
"""Composite "meal" support for diet_guard.
A meal is a named group of individually-macroed items -- e.g. a dinner of
salad + chicken + rice, each entered with its own calories and macros. The
meal's nutrition is the sum of its items. Both the individual items and the
composite meal are saved to the food bank (see
:func:`python_pkg.diet_guard._foodbank.remember_meal`), so next time each item
autocompletes on its own and the whole meal can be picked as one summed entry.
This module is deliberately pure (no I/O): the sum is a total function of its
items, which keeps the arithmetic exhaustively unit-testable apart from the
bank persistence and the gate UI that compose it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from python_pkg.diet_guard._estimator import Nutrition
if TYPE_CHECKING:
from collections.abc import Sequence
# Provenance stamped on a summed meal so the log/UI can tell a composite apart
# from a single looked-up food.
MEAL_SOURCE = "meal"
@dataclass(frozen=True)
class MealItem:
"""One named component of a composite meal, with its own nutrition.
Attributes:
name: The component's food name (e.g. ``"chicken"``).
nutrition: The component's resolved macros for the amount eaten.
"""
name: str
nutrition: Nutrition
def meal_total(items: Sequence[MealItem]) -> Nutrition:
"""Return the summed nutrition of a meal's items.
Every macro and the portion weight are added across the items and rounded to
0.1, and the result is stamped ``source=MEAL_SOURCE`` so it is
distinguishable from a single food. An empty sequence sums to an all-zero
meal rather than raising, so callers need not special-case "no items yet".
Args:
items: The meal's components.
Returns:
A :class:`~python_pkg.diet_guard._estimator.Nutrition` whose fields are
the per-item sums.
"""
return Nutrition(
kcal=round(sum((item.nutrition.kcal for item in items), 0.0), 1),
protein_g=round(sum((item.nutrition.protein_g for item in items), 0.0), 1),
carbs_g=round(sum((item.nutrition.carbs_g for item in items), 0.0), 1),
fat_g=round(sum((item.nutrition.fat_g for item in items), 0.0), 1),
grams=round(sum((item.nutrition.grams for item in items), 0.0), 1),
source=MEAL_SOURCE,
)

171
diet_guard/_portions.py Normal file
View File

@ -0,0 +1,171 @@
"""Built-in portion knowledge: unit weights and macros for common staples.
Two problems this solves, both seen in real use:
* **Counting by the piece.** People eat "5 apples", not "910 grams of apple".
To turn a count into grams the program needs to know what one piece weighs.
* **Open Food Facts is wrong for bare generics.** Searching OFF for "apple"
returns a packaged apple *pastry* (~500 kcal), not the fruit. For staple
whole foods a small, offline, curated table is both more correct and faster.
So this module gives diet_guard, for each common countable food, the typical
mass of one piece and its macros per 100 g. It is consulted *before* Open Food
Facts (see :mod:`python_pkg.diet_guard._resolve`), so a bare staple resolves
locally and sensibly, and a count multiplies cleanly into grams.
The numbers are deliberately round "good enough" averages (USDA ballpark); the
goal is a sane estimate the user can override with an explicit weight, not lab
precision.
"""
from __future__ import annotations
from dataclasses import dataclass
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._fuzzy import match_score
# Same close-match bar the food bank uses, so matching feels consistent.
_MATCH_THRESHOLD = 0.6
# Assumed mass of one piece when a counted food is not in the table, so "3 of
# something" still produces a number (flagged to the user as an assumption).
DEFAULT_ITEM_GRAMS = 100.0
@dataclass(frozen=True)
class Staple:
"""A common whole food: typical piece weight and per-100 g macros.
Attributes:
name: Canonical lowercase food name matched against the description.
unit_grams: Typical mass of one piece, in grams.
kcal_100: Calories per 100 g.
protein_100: Protein grams per 100 g.
carbs_100: Carbohydrate grams per 100 g.
fat_100: Fat grams per 100 g.
"""
name: str
unit_grams: float
kcal_100: float
protein_100: float
carbs_100: float
fat_100: float
# Per-100 g macros with one typical piece weight, for the common countable
# foods. Ordered roughly by how often they are eaten by the piece.
_STAPLES: tuple[Staple, ...] = (
Staple("apple", 182, 52, 0.3, 14.0, 0.2),
Staple("banana", 118, 89, 1.1, 23.0, 0.3),
Staple("orange", 131, 47, 0.9, 12.0, 0.1),
Staple("egg", 50, 143, 13.0, 1.1, 9.5),
Staple("boiled egg", 50, 155, 13.0, 1.1, 11.0),
Staple("slice of bread", 28, 265, 9.0, 49.0, 3.2),
Staple("potato", 173, 77, 2.0, 17.0, 0.1),
Staple("tomato", 123, 18, 0.9, 3.9, 0.2),
Staple("carrot", 61, 41, 0.9, 10.0, 0.2),
Staple("pear", 178, 57, 0.4, 15.0, 0.1),
Staple("peach", 150, 39, 0.9, 10.0, 0.3),
Staple("kiwi", 69, 61, 1.1, 15.0, 0.5),
Staple("mandarin", 74, 53, 0.8, 13.0, 0.3),
Staple("clementine", 74, 47, 0.9, 12.0, 0.2),
Staple("plum", 66, 46, 0.7, 11.0, 0.3),
Staple("strawberry", 12, 32, 0.7, 7.7, 0.3),
Staple("slice of pizza", 107, 266, 11.0, 33.0, 10.0),
Staple("rice cake", 9, 387, 8.0, 82.0, 2.8),
)
def _best_staple(description: str) -> Staple | None:
"""Return the staple best matching ``description``, or None below threshold.
Args:
description: Free-text food name (e.g. ``"apple"``, ``"apples"``).
Returns:
The closest :class:`Staple`, or None if nothing clears the match bar.
"""
key = description.strip().casefold()
if not key:
return None
best: Staple | None = None
best_score = _MATCH_THRESHOLD
for staple in _STAPLES:
score = match_score(key, staple.name)
if score > best_score:
best = staple
best_score = score
return best
def estimate_unit_grams(description: str) -> float | None:
"""Return the typical grams of one piece of ``description``, or None.
Args:
description: Free-text food name.
Returns:
The unit weight in grams for a known staple, else None (the caller then
falls back to :data:`DEFAULT_ITEM_GRAMS` and tells the user it guessed).
"""
staple = _best_staple(description)
return staple.unit_grams if staple is not None else None
def _staple_to_nutrition(staple: Staple) -> Nutrition:
"""Return a staple's per-100 g :class:`Nutrition` (source ``"staple: name"``)."""
return Nutrition(
kcal=staple.kcal_100,
protein_g=staple.protein_100,
carbs_g=staple.carbs_100,
fat_g=staple.fat_100,
grams=100.0,
source=f"staple: {staple.name}",
)
def staple_nutrition(description: str) -> Nutrition | None:
"""Return per-100 g :class:`Nutrition` for a known staple, else None.
The grams are fixed at 100 so the result is a clean reference basis the
caller can rescale to the actual amount eaten via
:func:`python_pkg.diet_guard._estimator.scale_nutrition`.
Args:
description: Free-text food name.
Returns:
The staple's per-100 g Nutrition (source ``"staple: <name>"``), or None.
"""
staple = _best_staple(description)
return _staple_to_nutrition(staple) if staple is not None else None
def suggest_staples(
query: str,
limit: int = 6,
) -> list[tuple[str, Nutrition]]:
"""Return staples whose name matches ``query``, best match first.
Used to surface built-in whole foods in the gate's live autocomplete (so
typing "apple" suggests the staple immediately, without a separate lookup
step), alongside the user's banked foods.
Args:
query: Free-text the user has typed so far.
limit: Maximum number of suggestions to return.
Returns:
``(name, per-100 g Nutrition)`` pairs, ranked, at most ``limit`` long.
"""
key = query.strip().casefold()
if not key:
return []
scored: list[tuple[float, Staple]] = []
for staple in _STAPLES:
score = match_score(key, staple.name)
if score >= _MATCH_THRESHOLD:
scored.append((score, staple))
scored.sort(key=lambda item: item[0], reverse=True)
return [(staple.name, _staple_to_nutrition(staple)) for _, staple in scored[:limit]]

161
diet_guard/_resolve.py Normal file
View File

@ -0,0 +1,161 @@
"""Resolve a food description to nutrition, food-bank first, OFF last.
This is the shared precedence both the CLI and the gate window use so a food is
always resolved the same way:
1. **Manual calories** the user typed -- always honored, always offline. Full
macros are recorded too when supplied.
2. **The food bank** -- a food the user has logged before is served from local
history with its remembered macros (no network).
3. **Open Food Facts** -- only for a brand-new food with no manual value, to
fill in macros the first time it is seen.
Keeping Open Food Facts strictly last is what makes the gate offline-safe: a
dead endpoint can never stop you logging a manual or already-known food, so the
lock can never trap you.
"""
from __future__ import annotations
from dataclasses import dataclass
from python_pkg.diet_guard._estimator import (
Nutrition,
estimate_off,
manual,
off_candidates,
scale_nutrition,
)
from python_pkg.diet_guard._foodbank import lookup_food, search_foods
from python_pkg.diet_guard._portions import staple_nutrition, suggest_staples
@dataclass(frozen=True)
class ManualMacros:
"""Calories and optional macros the user typed directly for a food.
Bundling these keeps :func:`resolve_nutrition` to a short argument list.
Attributes:
kcal: Calories entered directly; when supplied the lookups are skipped.
protein: Protein grams to record alongside ``kcal``.
carbs: Carbohydrate grams to record alongside ``kcal``.
fat: Fat grams to record alongside ``kcal``.
per_grams: Reference weight the macros are stated for (e.g. 100 for
"per 100 g" off a label). When given, the typed macros are scaled
from this basis to the eaten amount; when None they are taken as
totals for the portion (back-compatible behaviour).
"""
kcal: float
protein: float = 0.0
carbs: float = 0.0
fat: float = 0.0
per_grams: float | None = None
def resolve_nutrition(
description: str,
*,
grams: float | None = None,
manual_macros: ManualMacros | None = None,
) -> Nutrition | None:
"""Resolve ``description`` to a :class:`Nutrition`, or None if unresolvable.
Args:
description: Free-text food name.
grams: Amount actually eaten, in grams (used to rescale every source).
manual_macros: Calories and macros the user typed directly; when given,
they are recorded and the lookups are skipped entirely.
Returns:
The resolved Nutrition, or None only when no manual value was supplied,
the food is neither banked nor a known staple, and Open Food Facts
produced no usable match.
"""
if manual_macros is not None:
# The typed macros describe ``per_grams`` of food (the label basis);
# build that reference, then rescale it to the amount actually eaten so
# "200 kcal per 100 g, ate 330 g" logs 660 -- no manual arithmetic.
reference_grams = (
manual_macros.per_grams if manual_macros.per_grams is not None else grams
)
reference = manual(
manual_macros.kcal,
reference_grams,
protein_g=manual_macros.protein,
carbs_g=manual_macros.carbs,
fat_g=manual_macros.fat,
)
eaten = grams if grams is not None else reference_grams
return scale_nutrition(reference, eaten) if eaten is not None else reference
banked = lookup_food(description)
if banked is not None:
# Reuse the remembered macros, rescaled if a different amount was eaten.
return scale_nutrition(banked, grams) if grams is not None else banked
staple = staple_nutrition(description)
if staple is not None:
# A known whole food (apple, egg, ...) resolves locally and correctly,
# before Open Food Facts whose top "apple" hit is a packaged pastry.
return scale_nutrition(staple, grams) if grams is not None else staple
return estimate_off(description, grams)
def lookup_candidates(
description: str,
grams: float | None = None,
) -> list[tuple[str, Nutrition]]:
"""Return reviewable candidates for a food whose macros must be looked up.
Used by the gate when the user leaves the calorie field blank: it returns
the banked food if known (a single, instant, offline match), otherwise the
Open Food Facts alternatives so the user can pick the right product and see
where each value comes from. Empty means nothing resolved -- the caller
must then ask for a manual calorie value (the offline-safe escape).
Args:
description: Free-text food name the user typed.
grams: Portion size in grams, if the user supplied one.
Returns:
``(label, nutrition)`` pairs to show for review; at most one for a
banked food, otherwise the OFF candidates in relevance order.
"""
banked = lookup_food(description)
if banked is not None:
scaled = scale_nutrition(banked, grams) if grams is not None else banked
return [(description, scaled)]
staple = staple_nutrition(description)
if staple is not None:
scaled = scale_nutrition(staple, grams) if grams is not None else staple
return [(staple.source, scaled)]
return [
(nutrition.source, nutrition)
for nutrition in off_candidates(description, grams)
]
def suggest_foods(
query: str,
limit: int = 6,
) -> list[tuple[str, Nutrition]]:
"""Return live autocomplete suggestions: banked foods, then matching staples.
The user's own logged foods rank first (they are the most likely repeats);
built-in staples fill any remaining slots so common whole foods surface even
before they have ever been logged. A staple already covered by a banked
name is not duplicated.
Args:
query: Free-text the user has typed so far.
limit: Maximum number of suggestions to return.
Returns:
``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long.
"""
results = list(search_foods(query, limit))
seen = {name.casefold() for name, _ in results}
for name, nutrition in suggest_staples(query, limit):
if name.casefold() not in seen:
results.append((name, nutrition))
return results[:limit]

111
diet_guard/_slots.py Normal file
View File

@ -0,0 +1,111 @@
"""Pure meal-slot arithmetic for the diet_guard gate.
This module is deliberately I/O-free and clock-free: every function is a total
function of its ``now`` argument and the configured slot constants, so the
fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight
reset) are exhaustively unit-testable without mocking the filesystem or the
wall clock. The stateful "which slots have I actually logged?" question lives
in :mod:`python_pkg.diet_guard._state`; the two are composed in
:mod:`python_pkg.diet_guard._gate`.
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
16, 20). A slot is *elapsed* once its hour has arrived and we are still inside
the daily enforcement window; an elapsed slot with no logged meal is what makes
the gate fire.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from python_pkg.diet_guard._constants import (
GATE_DAY_START_HOUR,
GATE_EATING_END_HOUR,
GATE_SLOT_INTERVAL_HOURS,
)
if TYPE_CHECKING:
from datetime import datetime
_HOURS_PER_DAY = 24
def day_slots() -> tuple[int, ...]:
"""Return the fixed meal-slot hours for a day, e.g. ``(8, 12, 16, 20)``.
Slots run from the day-start hour, every interval, up to (but not past) the
overnight cutoff. Derived from the constants so changing the cadence in one
place reshapes the whole schedule.
Returns:
The slot hours in ascending order.
"""
return tuple(
range(GATE_DAY_START_HOUR, GATE_EATING_END_HOUR, GATE_SLOT_INTERVAL_HOURS)
)
def within_enforcement_window(now: datetime) -> bool:
"""Return True if ``now`` is inside the daily slot-enforcement window.
Outside ``[day_start, eating_end)`` the gate never fires, so unlogged slots
lapse overnight instead of trapping you at 03:00.
Args:
now: Reference local time.
Returns:
True if slot enforcement is active at ``now``.
"""
return GATE_DAY_START_HOUR <= now.hour < GATE_EATING_END_HOUR
def elapsed_slots(now: datetime) -> tuple[int, ...]:
"""Return today's slots whose hour has arrived as of ``now``.
Empty outside the enforcement window (before the first slot, or after the
overnight cutoff), so the caller never has to special-case the night.
Args:
now: Reference local time.
Returns:
The elapsed slot hours, ascending (possibly empty).
"""
if not within_enforcement_window(now):
return ()
return tuple(slot for slot in day_slots() if slot <= now.hour)
def missing_slots(now: datetime, logged: set[int]) -> tuple[int, ...]:
"""Return elapsed slots that have not been satisfied by a logged meal.
Args:
now: Reference local time.
logged: The set of slot hours already covered by today's log.
Returns:
The unsatisfied elapsed slot hours, ascending (empty == nothing due).
"""
return tuple(slot for slot in elapsed_slots(now) if slot not in logged)
def current_slot(now: datetime) -> int | None:
"""Return the most recent elapsed slot as of ``now``, or None.
Used to tag a meal logged through the plain ``ate`` CLI with the slot it
belongs to, so it counts toward that checkpoint.
Args:
now: Reference local time.
Returns:
The latest elapsed slot hour, or None when none have elapsed yet.
"""
elapsed = elapsed_slots(now)
return elapsed[-1] if elapsed else None
def slot_label(slot: int) -> str:
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``."""
return f"{slot % _HOURS_PER_DAY:02d}:00"

271
diet_guard/_state.py Normal file
View File

@ -0,0 +1,271 @@
"""HMAC-signed daily food log for diet_guard.
Each meal is stored as an individually HMAC-signed entry, reusing the shared
key at ``/etc/workout-locker/hmac.key`` -- the same key the screen locker uses
-- so the log is tamper-evident: editing the JSON to fake compliance
invalidates the signature. On a system without the shared key, entries are
written unsigned and still accepted on read, so the tool degrades gracefully
instead of silently losing the data it just wrote.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
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.log_integrity import (
compute_entry_hmac,
verify_entry_hmac,
)
if TYPE_CHECKING:
from python_pkg.diet_guard._estimator import Nutrition
_logger = logging.getLogger(__name__)
# On-disk shape: {"YYYY-MM-DD": [entry, entry, ...]}, newest entry last.
DayLog = dict[str, list[dict[str, object]]]
def now_local() -> datetime:
"""Return the current time as a timezone-aware local datetime."""
return datetime.now(tz=timezone.utc).astimezone()
def _today() -> str:
"""Return today's *local* date as ``YYYY-MM-DD``.
Local, not UTC: "what I ate today" is a local-calendar concept, and a meal
eaten late in the evening must not roll into tomorrow's budget.
"""
return now_local().date().isoformat()
def _entry_float(entry: dict[str, object], key: str) -> float:
"""Return ``entry[key]`` coerced to float (0.0 if missing/non-numeric).
``bool`` is rejected even though it subclasses ``int``: a boolean stored in
a calorie or macro field is meaningless and must not count as 1 gram.
Args:
entry: A stored log entry.
key: The numeric field name to read.
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
def entry_kcal(entry: dict[str, object]) -> float:
"""Return an entry's calorie count as a float (0.0 if missing/invalid)."""
return _entry_float(entry, "kcal")
def _read_raw_log() -> DayLog:
"""Read the log file without verification (empty dict on any error)."""
if not FOOD_LOG_FILE.exists():
return {}
try:
with FOOD_LOG_FILE.open() as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
_logger.warning("Cannot read food log %s", FOOD_LOG_FILE)
return {}
if not isinstance(data, dict):
return {}
result: DayLog = {}
for key, value in data.items():
if isinstance(key, str) and isinstance(value, list):
result[key] = [item for item in value if isinstance(item, dict)]
return result
def _write_log(log: DayLog) -> None:
"""Persist the full log to disk, creating the data directory if needed."""
FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with FOOD_LOG_FILE.open("w") as handle:
json.dump(log, handle, indent=2)
def _hmac_key_available() -> bool:
"""Return True if the shared HMAC key can be loaded for signing."""
return compute_entry_hmac({"_probe": True}) is not None
def _entry_is_valid(entry: dict[str, object]) -> bool:
"""Return True if an entry is untampered.
A signed entry must verify against the shared key. An unsigned entry is
accepted only when no key is available at all; an unsigned entry on a
system that *does* have a key means someone stripped the signature to
cheat, so it is rejected.
"""
if isinstance(entry.get("hmac"), str):
return verify_entry_hmac(entry)
return not _hmac_key_available()
def log_meal(
description: str,
nutrition: Nutrition,
slot: int | None = None,
) -> dict[str, object]:
"""Append a signed entry for ``description`` to today's log.
Args:
description: The user's free-text meal description.
nutrition: Estimated nutrition for the portion eaten.
slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the
12:00 checkpoint). When None the entry still counts toward the
day's calories but does not mark any slot as logged.
Returns:
The stored entry dict (carrying an ``hmac`` field when a key exists).
"""
entry: dict[str, object] = {
"time": now_local().isoformat(timespec="seconds"),
"desc": description,
"grams": nutrition.grams,
"kcal": nutrition.kcal,
"protein_g": nutrition.protein_g,
"carbs_g": nutrition.carbs_g,
"fat_g": nutrition.fat_g,
"source": nutrition.source,
}
if slot is not None:
entry["slot"] = slot
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable - logging unsigned entry")
log = _read_raw_log()
log.setdefault(_today(), []).append(entry)
_write_log(log)
return entry
def load_log() -> DayLog:
"""Return the log with only valid (untampered) entries retained."""
raw = _read_raw_log()
verified: DayLog = {}
for day, entries in raw.items():
kept = [entry for entry in entries if _entry_is_valid(entry)]
if kept:
verified[day] = kept
return verified
def today_entries() -> list[dict[str, object]]:
"""Return today's valid log entries (possibly empty)."""
return load_log().get(_today(), [])
def today_total_kcal() -> float:
"""Return total kcal logged today across valid entries."""
total = sum(entry_kcal(entry) for entry in today_entries())
return round(total, 1)
def today_total_macros() -> tuple[float, float, float]:
"""Return today's total ``(protein_g, carbs_g, fat_g)`` across valid entries.
Returned as a fixed ``(protein, carbs, fat)`` triple so callers (the gate
dashboard, the CLI status) can show how the day's macros are stacking up
next to the calorie total.
Returns:
The summed protein, carbohydrate, and fat grams, each rounded to 0.1 g.
"""
entries = today_entries()
protein = sum(_entry_float(entry, "protein_g") for entry in entries)
carbs = sum(_entry_float(entry, "carbs_g") for entry in entries)
fat = sum(_entry_float(entry, "fat_g") for entry in entries)
return round(protein, 1), round(carbs, 1), round(fat, 1)
def logged_slots_today() -> set[int]:
"""Return the set of meal-slot hours already covered by today's log.
Only valid (HMAC-verified) entries count, so stripping entries to dodge a
checkpoint makes that slot reappear as unsatisfied -- the fail-closed
direction. An entry without a ``slot`` field (e.g. a snack logged with no
checkpoint) contributes calories but satisfies no slot.
Returns:
The distinct integer slot hours logged today (possibly empty).
"""
slots: set[int] = set()
for entry in today_entries():
value = entry.get("slot")
if isinstance(value, int) and not isinstance(value, bool):
slots.add(value)
return slots
def remaining_budget() -> float:
"""Return kcal remaining against the sealed budget (may be negative).
Raises:
BudgetError: If the budget is uninitialized or its seal is broken;
the caller decides whether to guide the user or fail closed.
"""
return round(daily_budget() - today_total_kcal(), 1)
def consumption_band() -> str:
"""Return a qualitative band for today's intake, never revealing the budget.
Mirrors how the focus daemon surfaces "at home?" rather than the raw
coordinates: the caller learns whether to worry, not the number behind the
threshold. The threshold still leaks by boundary-probing (watch the label
flip), so this hides the anchor, it does not make the budget unrecoverable.
Returns:
``"OVER BUDGET"``, ``"approaching limit"``, or ``"on track"``.
Raises:
BudgetError: Propagated from :func:`daily_budget` for the caller to
translate into guidance.
"""
budget = daily_budget()
consumed = today_total_kcal()
if consumed >= budget:
return "OVER BUDGET"
if consumed >= budget * BUDGET_WARN_FRACTION:
return "approaching limit"
return "on track"
def undo_last_today() -> dict[str, object] | None:
"""Remove and return today's most recently logged entry, if any.
Operates on the raw log so a mistaken entry can always be removed, even one
that would not pass verification.
Returns:
The removed entry, or None if nothing was logged today.
"""
log = _read_raw_log()
today = _today()
entries = log.get(today)
if not entries:
return None
removed = entries.pop()
if entries:
log[today] = entries
else:
del log[today]
_write_log(log)
return removed

View File

@ -0,0 +1,25 @@
[Unit]
Description=Diet Guard log-to-unlock gate (periodic check)
After=graphical-session.target
[Service]
Type=oneshot
# DISPLAY/PYTHONPATH mirror wake-alarm.service: the gate opens a Tk window when a
# lock is due, so without DISPLAY it would crash with "no display name and no
# $DISPLAY" before it could even check. The command self-checks gate_is_due() and
# exits 0 when no lock is needed, so running it every ~30 min is cheap.
#
# XAUTHORITY pins the X auth cookie path explicitly. It is belt-and-suspenders,
# not the fix: when this unit fires at SESSION START (Persistent=true catch-up),
# it can beat the display manager writing ~/.Xauthority, so the cookie is simply
# absent yet -- pointing at it does not help. That race is handled in Python by
# wait_for_display(), which polls the display until it is connectable before
# opening the window (previously a session-start launch died on a "couldn't
# connect to display" TclError and never showed). The sleep gives the session a
# brief head start; the Python wait is what makes it reliable.
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
Environment=PYTHONPATH=%h/testsAndMisc
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python -m python_pkg.diet_guard gate
WorkingDirectory=%h/testsAndMisc

View File

@ -0,0 +1,20 @@
[Unit]
Description=Periodically run the Diet Guard gate check
[Timer]
# Check on a WALL-CLOCK schedule (every 30 min, on the hour and half-hour).
# The 5-hour "log a meal" threshold and the 08:00-22:00 eating window are
# enforced inside gate_is_due(), so the timer only needs to fire often enough to
# notice the boundary; 30 min is plenty.
#
# Why OnCalendar and not OnBootSec/OnUnitActiveSec: the gate is a *blocking*
# oneshot -- it stays up until a meal is logged -- and a monotonic schedule
# computed no next elapse once the service had run (and none at all across a
# reboot), leaving the timer dormant (NextElapse=infinity). OnCalendar re-arms
# every period regardless of how long the gate stayed open, and Persistent
# catches a run missed while the machine was suspended.
OnCalendar=*-*-* *:00/30:00
Persistent=true
[Install]
WantedBy=timers.target

54
diet_guard/docs/design.md Normal file
View File

@ -0,0 +1,54 @@
I turned on pc today and diet guard did not work... pc got turned on at ~18:52 but the diet guard did not show, it should show immediately since there were no meals logged at
08:00 12:00 and 16:00 (since pc was turned off)
How it currently works:
It triggers every 5hr if no food was recorded as eaten
Issue:
It is semi-automatic, it assumes user will manually write down food so far, they will not
also 5hr is too much
What it should do:
Every 4hrs (STARTING at the "beginning of the day" so currently 8 AM) open a locker asking to fill what food was eaten, do it for every 4hrs (so next at 12:00 next at 16:00 next at 20:00) this has 2 benefits:
1. is fully automatic
2. makes user eat regularly which makes keeping diet easier
Initially user should write down name of the food, its FULL macro
(calories, protein, carbs and fats)
the diet_guard should hold this info in a "bank" of food info, so that next time this popup comes user
can
1. Write down the food manually again
2. The input should suggest what food do they want to write down (think autocomplete)
3. User should be also able to expand list of food and choose from this list, as user writes down in
input this list should be filtered to match whatever used wrote down (some smart filtering, not
literally "if the food begins with this name" user can make typos, write something similar but
not exactly the same and so on) -> this should be a LOCAL database but we should use
open food data (or whatever we are using right now) to help us fill it but the search should
only use historical data of what the user filled in before
This every 4hrs process should also inform user how many calories they have left out of total calories
for the day
This is for Friday-Monday INCLUDING, for Tuesday, Wednesday, Thursday it should work a bit different
assume that user comebacks late (say 5PM or later)
LOCK the screen so user fills out full food intake for this day so far, make this a requiremetn to
access the PC
after that work as before so show diet lock at specific hour (lets say they come at
5PM so probably at 8PM)
Ok in fact I think we should not have 2 different processes, make it a one process that accumulates
"food times"
so lets say user turn on pc before 8AM show nothing and at 8AM show something
if user turns on pc after 8AM but before 12:00 show only this one 8AM food time
if user turns on lets say 17:00 make them fill data for 8AM, 12:00 and 16:00..
and so on
Another feature would be allowing for complicated "meal" type items, so for example I would like to log that at 12:00 I ate a soup and a meal
soup with specific macro
and dinner which consisted of
salad
chicken
rice
each having their own macro that I would want to fill for all of them separately and make the program
calculate the sum of it, both the individual items and the meal itself should be saved to database

77
diet_guard/install.sh Executable file
View File

@ -0,0 +1,77 @@
#!/bin/bash
# ============================================================================
# Diet Guard installer: hidden budget + log-to-unlock gate.
#
# Usage: bash install.sh
#
# What it does:
# 1. Ensures system deps (setxkbmap for VT-disable, requests for OFF lookups)
# 2. Installs + enables the systemd user timer that fires the gate every ~30m
# 3. Seals your daily budget from biometrics (only if not already sealed)
# 4. Locks the budget file immutable with `chattr +i` (the real tamper gate)
# ============================================================================
set -euo pipefail
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
# python_pkg/diet_guard -> repo root (two levels up).
REPO_DIR="$(readlink -f "$SCRIPT_DIR/../..")"
readonly REPO_DIR
readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service"
readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
readonly DATA_DIR="$HOME/.local/share/diet_guard"
readonly BUDGET_FILE="$DATA_DIR/.budget"
echo "=== Diet Guard Installer ==="
# 1. System dependencies ------------------------------------------------------
echo "[1/4] Checking system dependencies..."
if ! command -v setxkbmap &>/dev/null; then
echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..."
sudo pacman -S --noconfirm xorg-setxkbmap
else
echo " setxkbmap present"
fi
if ! python -c 'import requests' 2>/dev/null; then
echo " Installing python-requests (Open Food Facts lookups)..."
sudo pacman -S --noconfirm python-requests
else
echo " python-requests present"
fi
# 2. systemd user timer + service --------------------------------------------
echo "[2/4] Installing systemd user timer + service..."
mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
systemctl --user daemon-reload
systemctl --user enable --now diet-guard-gate.timer
echo " Timer enabled and started (fires the gate every ~30 min)."
# 3. Seal the daily budget (hidden) ------------------------------------------
echo "[3/4] Sealing your daily budget..."
if [[ -e "$BUDGET_FILE" ]]; then
echo " Budget already sealed at $BUDGET_FILE - skipping init."
else
echo " Enter your biometrics (used once then discarded; the value is hidden):"
(cd "$REPO_DIR" && python -m python_pkg.diet_guard init)
fi
# 4. Lock the budget immutable (the real tamper friction) --------------------
echo "[4/4] Locking the budget file (chattr +i)..."
read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)"
if [[ "$attrs" == *i* ]]; then
echo " Already immutable."
else
sudo chattr +i "$BUDGET_FILE"
echo " Locked. To change it later: sudo chattr -i '$BUDGET_FILE'; re-run init; re-lock."
fi
echo "=== Installation complete ==="
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
echo "once you have gone 5h without logging."
echo "Test the lock now (safe, closeable): \
cd $REPO_DIR && python -m python_pkg.diet_guard gate --demo"

View File

View File

@ -0,0 +1,69 @@
"""Shared fixtures for diet_guard tests.
Two safety nets run for every test:
* ``_isolate_state`` redirects the food log, sealed budget, and gate lock into
``tmp_path`` so a test can never read or clobber the real ``~/.local/share``.
* ``_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.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def _isolate_state(tmp_path: Path) -> Iterator[None]:
"""Redirect all on-disk diet_guard state into a temp dir."""
with (
patch(
"python_pkg.diet_guard._budget.BUDGET_FILE",
tmp_path / ".budget",
),
patch(
"python_pkg.diet_guard._state.FOOD_LOG_FILE",
tmp_path / "food_log.json",
),
patch(
"python_pkg.diet_guard._foodbank.FOOD_BANK_FILE",
tmp_path / "food_bank.json",
),
patch(
"python_pkg.diet_guard._gatelock.GATE_LOCK_FILE",
tmp_path / ".gate.lock",
),
):
yield
@pytest.fixture(autouse=True)
def _block_real_tk() -> Iterator[None]:
"""Replace tk + the window class in _gatelock so no real window can open."""
with (
patch("python_pkg.diet_guard._gatelock.tk", MagicMock()),
patch("python_pkg.diet_guard._gatelock._GateRoot", MagicMock()),
):
yield
@pytest.fixture(autouse=True)
def _hmac_key(tmp_path: Path) -> Iterator[None]:
"""Point the shared HMAC key at a deterministic temp file.
Makes signing/verification work the same in any environment (including CI,
which has no ``/etc/workout-locker/hmac.key``). Tests that need the
no-key path patch ``compute_entry_hmac`` to return None locally.
"""
key = tmp_path / "hmac.key"
key.write_bytes(b"diet-guard-test-key-0123456789ab")
with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key):
yield

View File

@ -0,0 +1,272 @@
"""Tests for _budget.py — the hidden, tamper-hardened daily budget."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import TYPE_CHECKING, cast
from unittest.mock import patch
import pytest
from python_pkg.diet_guard import _budget
from python_pkg.diet_guard._budget import (
Biometrics,
BudgetLockedError,
BudgetNotInitializedError,
BudgetSealBrokenError,
budget_weight,
compute_target_budget,
daily_budget,
is_initialized,
lock_command,
mifflin_st_jeor_bmr,
protein_target_g,
seal_budget,
unlock_command,
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# A reusable, realistic body profile (the user's own stats).
_BIO = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=True)
def _write_record(record: object) -> None:
"""Write an arbitrary object as the seal file (for tamper tests)."""
_budget.BUDGET_FILE.write_text(json.dumps(record), encoding="utf-8")
def _budget_open_raises(exc: type[BaseException]) -> object:
"""Patch ``Path.open`` to raise ``exc`` ONLY for the sealed-budget file.
``Path`` instances use ``__slots__`` so ``patch.object(BUDGET_FILE, "open")``
fails; and patching ``Path.open`` wholesale would also break the unrelated
HMAC-key read inside ``compute_entry_hmac``. Routing every other path to the
real ``open`` keeps the failure surgically on the budget file.
Args:
exc: The exception type to raise when the budget file is opened.
Returns:
An unstarted ``patch`` context manager.
"""
# Capture the real opener as a permissive callable so forwarding the
# patched-through args (typed ``object`` here) is not rejected on arg types.
real_open = cast("Callable[..., Iterator[str]]", Path.open)
def fake_open(self: Path, *args: object, **kwargs: object) -> Iterator[str]:
if self == _budget.BUDGET_FILE:
raise exc
return real_open(self, *args, **kwargs)
return patch("pathlib.Path.open", new=fake_open)
class TestMifflinStJeor:
"""The BMR formula's two sex branches."""
def test_male_constant(self) -> None:
"""Male uses the +5 constant."""
# 10*80 + 6.25*169 - 5*26 + 5 = 1731.25
assert mifflin_st_jeor_bmr(_BIO) == pytest.approx(1731.25)
def test_female_constant(self) -> None:
"""Female uses the -161 constant."""
bio = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=False)
assert mifflin_st_jeor_bmr(bio) == pytest.approx(1731.25 - 166.0)
class TestComputeTargetBudget:
"""TDEE minus deficit, with a safety floor."""
def test_typical_value(self) -> None:
"""A light-activity, modest-deficit target rounds as expected."""
# 1731.25 * 1.375 - 180 = 2200.46... -> 2200
result = compute_target_budget(_BIO, activity_factor=1.375, deficit_kcal=180)
assert result == 2200
def test_floored_to_minimum(self) -> None:
"""An absurd deficit cannot seal a starvation-level budget."""
result = compute_target_budget(_BIO, activity_factor=1.0, deficit_kcal=5000)
assert result == _budget._MIN_SANE_BUDGET
class TestExceptions:
"""Each budget error carries a fixed message."""
def test_messages(self) -> None:
"""Constructors set a non-empty message with no arguments."""
assert str(BudgetNotInitializedError())
assert str(BudgetSealBrokenError())
assert str(BudgetLockedError())
class TestSealAndRead:
"""Round-tripping the sealed budget."""
def test_roundtrip(self) -> None:
"""A sealed value reads back exactly."""
seal_budget(2000)
assert daily_budget() == 2000
def test_is_initialized(self) -> None:
"""is_initialized reflects whether the file exists."""
assert not is_initialized()
seal_budget(2000)
assert is_initialized()
def test_file_is_not_plaintext(self) -> None:
"""The number is base64-wrapped, not stored as a bare integer."""
seal_budget(2345)
raw = _budget.BUDGET_FILE.read_text(encoding="utf-8")
assert "2345" not in raw
def test_unsigned_accepted_when_no_key(self) -> None:
"""With no HMAC key, an unsigned seal is written and accepted."""
with patch.object(_budget, "compute_entry_hmac", return_value=None):
seal_budget(1800)
record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8"))
assert "hmac" not in record
assert daily_budget() == 1800
def test_locked_file_raises(self) -> None:
"""An unwritable (immutable) file surfaces as BudgetLockedError."""
with _budget_open_raises(PermissionError), pytest.raises(BudgetLockedError):
seal_budget(2000)
class TestReadFailures:
"""daily_budget's defensive paths."""
def test_missing_file(self) -> None:
"""No file yet -> not initialized."""
with pytest.raises(BudgetNotInitializedError):
daily_budget()
def test_unreadable_file(self) -> None:
"""An OSError while reading surfaces as a broken seal."""
seal_budget(2000)
with _budget_open_raises(OSError), pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_invalid_json(self) -> None:
"""Garbage content -> broken seal."""
_budget.BUDGET_FILE.write_text("not json", encoding="utf-8")
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_record_not_dict(self) -> None:
"""A non-object top level -> broken seal."""
_write_record([1, 2, 3])
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_data_not_string(self) -> None:
"""A non-string data field -> broken seal."""
_write_record({"data": 123})
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_bad_base64(self) -> None:
"""Undecodable base64 -> broken seal."""
_write_record({"data": "!!!not base64!!!"})
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_inner_not_dict(self) -> None:
"""base64 that decodes to a non-object -> broken seal."""
inner = base64.b64encode(b"[1,2,3]").decode("ascii")
_write_record({"data": inner})
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_tampered_signature(self) -> None:
"""A forged value with a bad signature is rejected."""
forged = base64.b64encode(b'{"b":9999,"v":1}').decode("ascii")
_write_record({"data": forged, "hmac": "deadbeef"})
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_unsigned_rejected_when_key_available(self) -> None:
"""A stripped signature on a keyed system means tampering."""
valid = base64.b64encode(b'{"b":2000,"v":1}').decode("ascii")
_write_record({"data": valid}) # no hmac, but a key exists
with pytest.raises(BudgetSealBrokenError):
daily_budget()
def test_signature_present_but_key_missing(self) -> None:
"""A signed seal cannot be verified once the key is gone."""
seal_budget(2000)
with (
patch.object(
_budget,
"compute_entry_hmac",
return_value=None,
),
pytest.raises(BudgetSealBrokenError),
):
daily_budget()
def test_non_integer_value(self) -> None:
"""A non-integer budget (here a bool) is rejected."""
# Sign a record whose inner "b" is a bool, so the signature is valid but
# the value type is wrong.
inner = {"v": 1, "b": True}
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
record = {
"data": base64.b64encode(blob).decode("ascii"),
"hmac": _budget.compute_entry_hmac(inner),
}
_write_record(record)
with pytest.raises(BudgetSealBrokenError):
daily_budget()
class TestWeightAndProtein:
"""The v2 stored weight and the protein target derived from it."""
def test_seal_with_weight_roundtrips(self) -> None:
"""A weight sealed alongside the budget reads back."""
seal_budget(2200, weight_kg=80.0)
assert daily_budget() == 2200
assert budget_weight() == pytest.approx(80.0)
def test_protein_target_from_weight(self) -> None:
"""The protein target is weight x the per-kg constant."""
seal_budget(2200, weight_kg=80.0)
expected = round(80.0 * _budget.PROTEIN_G_PER_KG, 1)
assert protein_target_g() == pytest.approx(expected)
def test_v1_seal_has_no_weight(self) -> None:
"""A budget sealed without a weight exposes no weight or protein target."""
seal_budget(2000)
assert budget_weight() is None
assert protein_target_g() is None
def test_protein_target_none_when_uninitialized(self) -> None:
"""With nothing sealed, the protein target is quietly None, not an error."""
assert protein_target_g() is None
def test_budget_weight_rejects_non_numeric(self) -> None:
"""A validly-signed but non-numeric weight yields None, not a crash."""
inner = {"v": 2, "b": 2000, "w": True}
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
record = {
"data": base64.b64encode(blob).decode("ascii"),
"hmac": _budget.compute_entry_hmac(inner),
}
_write_record(record)
assert budget_weight() is None
class TestCommands:
"""The chattr helper strings."""
def test_lock_unlock_commands(self) -> None:
"""Both reference the budget path with the right chattr flag."""
assert lock_command().startswith("sudo chattr +i ")
assert unlock_command().startswith("sudo chattr -i ")
assert str(_budget.BUDGET_FILE) in lock_command()

View File

@ -0,0 +1,279 @@
"""Tests for _cli.py — argument parsing and subcommand dispatch.
Subsystems (budget, resolution, logging, the gate window) are mocked so each
command's branches are exercised without touching real state or opening a
window; stdin is scripted via ``StringIO`` and stdout captured with ``capsys``.
"""
from __future__ import annotations
import io
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.diet_guard import _cli
from python_pkg.diet_guard._budget import (
BudgetLockedError,
BudgetNotInitializedError,
BudgetSealBrokenError,
seal_budget,
)
from python_pkg.diet_guard._cli import _eaten_grams, _Portion, main
from python_pkg.diet_guard._estimator import Nutrition
if TYPE_CHECKING:
import pytest
_NUT = Nutrition(250, 12, 30, 10, 200, "manual")
_VALID_INIT = "80\n169\n26\nm\n1.375\n180\n"
def _feed(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
"""Point stdin at scripted ``text`` for the prompts a command reads."""
monkeypatch.setattr("sys.stdin", io.StringIO(text))
class TestEatenGrams:
"""Turning a portion into grams, with the assumption note."""
def test_count_of_known_staple(self) -> None:
"""A count of a known staple multiplies by its unit weight, no note."""
grams, note = _eaten_grams(
"apple", _Portion(grams=None, count=5, per_grams=None)
)
assert grams == 5 * 182
assert note is None
def test_count_of_unknown_item_warns(self) -> None:
"""A count of an unknown item uses the default and flags the assumption."""
grams, note = _eaten_grams(
"mystery", _Portion(grams=None, count=3, per_grams=None)
)
assert grams is not None
assert note is not None
def test_explicit_grams(self) -> None:
"""An explicit gram portion passes straight through."""
grams, note = _eaten_grams("x", _Portion(grams=300, count=None, per_grams=None))
assert grams == 300
assert note is None
class TestInit:
"""The budget-sealing init command."""
def test_valid_male(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Valid inputs seal a budget and print the lock hint, not the number."""
_feed(monkeypatch, _VALID_INIT)
assert main(["init"]) == 0
assert "sealed" in capsys.readouterr().out
def test_valid_female(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The female sex branch is accepted."""
_feed(monkeypatch, "80\n169\n26\nf\n1.375\n180\n")
assert main(["init"]) == 0
def test_non_number_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-numeric input seals nothing and returns the error code."""
_feed(monkeypatch, "heavy\n")
assert main(["init"]) == 2
def test_bad_sex_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""An unrecognised sex answer seals nothing."""
_feed(monkeypatch, "80\n169\n26\nx\n1.375\n180\n")
assert main(["init"]) == 2
def test_locked_budget(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A locked file surfaces the unlock instructions and a failure code."""
_feed(monkeypatch, _VALID_INIT)
with patch.object(_cli, "seal_budget", side_effect=BudgetLockedError):
assert main(["init"]) == 1
assert "locked" in capsys.readouterr().out
class TestSummary:
"""The budget-remaining summary line."""
def test_not_initialized(self, capsys: pytest.CaptureFixture[str]) -> None:
"""No budget yet -> a guiding hint, no crash."""
with patch.object(_cli, "daily_budget", side_effect=BudgetNotInitializedError):
_cli._print_summary()
assert "budget not set" in capsys.readouterr().out
def test_seal_broken(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A broken seal is reported plainly."""
with patch.object(_cli, "daily_budget", side_effect=BudgetSealBrokenError):
_cli._print_summary()
assert "seal broken" in capsys.readouterr().out
def test_remaining_shown(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A valid budget prints how much is left."""
seal_budget(2000)
_cli._print_summary()
assert "left" in capsys.readouterr().out
class TestAte:
"""Logging a meal from the command line."""
def test_logs_and_summarizes(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A resolved meal is logged, banked, and summarized."""
seal_budget(2000)
with patch.object(_cli, "resolve_nutrition", return_value=_NUT):
assert main(["ate", "big mac"]) == 0
assert "logged:" in capsys.readouterr().out
def test_note_printed_for_assumed_weight(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""An assumed per-item weight prints its caveat."""
seal_budget(2000)
with patch.object(_cli, "resolve_nutrition", return_value=_NUT):
main(["ate", "mystery", "--count", "3"])
assert "assumed" in capsys.readouterr().out
def test_unresolved_food(self, capsys: pytest.CaptureFixture[str]) -> None:
"""An unresolvable food returns a failure and a manual-entry hint."""
with patch.object(_cli, "resolve_nutrition", return_value=None):
assert main(["ate", "nonsense"]) == 1
assert "--kcal" in capsys.readouterr().out
class TestStatus:
"""The status report."""
def test_status_with_entries(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Logged entries, slots, summary, and macros all print."""
seal_budget(2000)
main(["ate", "lunch", "--kcal", "500"])
capsys.readouterr()
assert main(["status"]) == 0
out = capsys.readouterr().out
assert "slots:" in out
assert "macros:" in out
def test_status_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
"""With nothing logged, status still prints the slot/summary lines."""
seal_budget(2000)
assert main(["status"]) == 0
assert "slots:" in capsys.readouterr().out
def test_macro_status_with_target(self, capsys: pytest.CaptureFixture[str]) -> None:
"""When a protein target is known, it is shown alongside the macros."""
with patch.object(_cli, "protein_target_g", return_value=144.0):
_cli._print_macro_status()
assert "protein" in capsys.readouterr().out
def test_macro_status_without_target(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""With no target, only the running macros are shown."""
with patch.object(_cli, "protein_target_g", return_value=None):
_cli._print_macro_status()
out = capsys.readouterr().out
assert "macros:" in out
assert "protein" not in out
def test_slot_status_all_marks(self, capsys: pytest.CaptureFixture[str]) -> None:
"""The slot line shows logged / DUE / upcoming together."""
with (
patch.object(_cli, "logged_slots_today", return_value={8}),
patch.object(_cli, "due_slots", return_value=[12]),
):
_cli._print_slot_status()
out = capsys.readouterr().out
assert "logged" in out
assert "DUE" in out
assert "upcoming" in out
class TestUndo:
"""Removing the most recent entry."""
def test_nothing_to_undo(self, capsys: pytest.CaptureFixture[str]) -> None:
"""An empty day reports nothing to undo."""
assert main(["undo"]) == 0
assert "nothing to undo" in capsys.readouterr().out
def test_undo_removes_entry(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Undo removes and reports the last entry."""
seal_budget(2000)
main(["ate", "snack", "--kcal", "100"])
capsys.readouterr()
assert main(["undo"]) == 0
assert "removed:" in capsys.readouterr().out
class TestGate:
"""The gate subcommand's three modes."""
def test_check_due(self, capsys: pytest.CaptureFixture[str]) -> None:
"""--check exits 1 and announces a due lock."""
with patch.object(_cli, "gate_is_due", return_value=True):
assert main(["gate", "--check"]) == 1
assert "due" in capsys.readouterr().out
def test_check_not_due(self) -> None:
"""--check exits 0 when no lock is needed."""
with patch.object(_cli, "gate_is_due", return_value=False):
assert main(["gate", "--check"]) == 0
def test_demo_opens_window(self) -> None:
"""--demo always builds and runs the gate window."""
gate = MagicMock()
with (
patch.object(_cli, "MealGate", return_value=gate) as factory,
patch.object(_cli, "acquire_gate_lock", return_value=MagicMock()),
patch.object(_cli, "release_gate_lock"),
):
assert main(["gate", "--demo"]) == 0
factory.assert_called_once_with(demo_mode=True)
gate.run.assert_called_once()
def test_bare_gate_not_due(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A bare gate with nothing due just reports and exits."""
with patch.object(_cli, "gate_is_due", return_value=False):
assert main(["gate"]) == 0
assert "no lock needed" in capsys.readouterr().out
def test_bare_gate_due_opens_window(self) -> None:
"""A bare gate that is due opens the real window."""
gate = MagicMock()
with (
patch.object(_cli, "gate_is_due", return_value=True),
patch.object(_cli, "MealGate", return_value=gate),
patch.object(_cli, "acquire_gate_lock", return_value=MagicMock()),
patch.object(_cli, "release_gate_lock"),
):
assert main(["gate"]) == 0
gate.run.assert_called_once()
def test_gate_already_running(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A held single-instance lock means a second window is not opened."""
with (
patch.object(_cli, "gate_is_due", return_value=True),
patch.object(_cli, "acquire_gate_lock", return_value=None),
patch.object(_cli, "MealGate") as factory,
):
assert main(["gate"]) == 0
factory.assert_not_called()
assert "already running" in capsys.readouterr().out
def test_gate_due_but_display_not_ready_defers(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""A due gate whose display never comes up defers without a window."""
with (
patch.object(_cli, "gate_is_due", return_value=True),
patch.object(_cli, "acquire_gate_lock", return_value=MagicMock()),
patch.object(_cli, "release_gate_lock"),
patch.object(_cli, "wait_for_display", return_value=False),
patch.object(_cli, "MealGate") as factory,
):
assert main(["gate"]) == 0
factory.assert_not_called()
assert "display not ready" in capsys.readouterr().out

View File

@ -0,0 +1,220 @@
"""Tests for _estimator.py — Nutrition maths and the Open Food Facts backend.
The HTTP layer is fully mocked (``requests.get``), so the parsing, portion, and
scaling branches are exercised without any network access.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import requests
from python_pkg.diet_guard import _estimator
from python_pkg.diet_guard._constants import DEFAULT_PORTION_GRAMS
from python_pkg.diet_guard._estimator import (
Nutrition,
estimate,
estimate_off,
manual,
off_candidates,
scale_nutrition,
)
_GOOD = {
"product_name": "Big Mac",
"nutriments": {
"energy-kcal_100g": 250,
"proteins_100g": 12,
"carbohydrates_100g": 30,
"fat_100g": 10,
},
"serving_quantity": 150,
}
def _patch_get(payload: object) -> object:
"""Patch ``requests.get`` to return a response whose JSON is ``payload``."""
response = MagicMock()
response.raise_for_status = MagicMock()
response.json = MagicMock(return_value=payload)
return patch.object(_estimator.requests, "get", return_value=response)
def _hits(*products: object) -> dict[str, object]:
"""Wrap products in the Search-a-licious ``hits`` envelope."""
return {"hits": list(products)}
class TestAsFloat:
"""Coercion of OFF numeric fields, including the rejected types."""
def test_bool_rejected(self) -> None:
"""A bool is not a real nutriment value."""
assert _estimator._as_float(value=True) is None
def test_int_and_float(self) -> None:
"""Ints and floats pass straight through."""
assert _estimator._as_float(5) == 5.0
assert _estimator._as_float(2.5) == 2.5
def test_numeric_string(self) -> None:
"""A numeric string parses."""
assert _estimator._as_float("3.5") == 3.5
def test_non_numeric_string(self) -> None:
"""A non-numeric string is None."""
assert _estimator._as_float("abc") is None
def test_other_type(self) -> None:
"""An unrelated type (None) is None."""
assert _estimator._as_float(None) is None
class TestManual:
"""User-supplied nutrition."""
def test_with_grams(self) -> None:
"""Grams are kept for display; source is manual."""
result = manual(500, 250, protein_g=20, carbs_g=40, fat_g=15)
assert result == Nutrition(500.0, 20.0, 40.0, 15.0, 250.0, "manual")
def test_without_grams(self) -> None:
"""Omitting grams records 0.0."""
assert manual(300).grams == 0.0
class TestScaleNutrition:
"""Proportional rescaling and its degenerate guards."""
def test_normal_scaling(self) -> None:
"""Doubling the grams doubles every macro."""
base = Nutrition(100, 10, 5, 2, 100, "x")
scaled = scale_nutrition(base, 200)
assert (scaled.kcal, scaled.protein_g, scaled.grams) == (200.0, 20.0, 200.0)
def test_unknown_basis_keeps_macros(self) -> None:
"""A zero basis cannot scale, so macros stay and only grams update."""
base = Nutrition(100, 10, 5, 2, 0, "x")
scaled = scale_nutrition(base, 250)
assert scaled.kcal == 100
assert scaled.grams == 250
def test_non_positive_new_grams_keeps_basis_grams(self) -> None:
"""A non-positive target weight keeps the basis weight, macros intact."""
base = Nutrition(100, 10, 5, 2, 100, "x")
scaled = scale_nutrition(base, 0)
assert scaled.kcal == 100
assert scaled.grams == 100
class TestOffSearchEnvelope:
"""Defensive parsing of the search payload shape."""
def test_payload_not_dict(self) -> None:
"""A non-object payload yields no candidates."""
with _patch_get("not a dict"):
assert off_candidates("x") == []
def test_hits_not_list(self) -> None:
"""A non-list ``hits`` yields no candidates."""
with _patch_get({"hits": 123}):
assert off_candidates("x") == []
class TestOffCandidates:
"""Building Nutrition from products, with filtering and portions."""
def test_filters_unusable_products(self) -> None:
"""Non-dict hits, bad nutriments, and kcal-less products are dropped."""
with _patch_get(
_hits(
"junk-string",
{"product_name": "NoNutr", "nutriments": "bad"},
{"product_name": "NoKcal", "nutriments": {"proteins_100g": 5}},
_GOOD,
),
):
results = off_candidates("big mac")
assert len(results) == 1
assert results[0].source == "openfoodfacts: Big Mac"
def test_explicit_grams_override_serving(self) -> None:
"""An explicit portion takes priority over the serving size."""
with _patch_get(_hits(_GOOD)):
result = off_candidates("big mac", grams=200)[0]
assert result.grams == 200
assert result.kcal == 500
def test_serving_quantity_used_when_no_grams(self) -> None:
"""With no grams, the product's serving size sets the portion."""
with _patch_get(_hits(_GOOD)):
result = off_candidates("big mac")[0]
assert result.grams == 150
def test_default_portion_when_nothing_known(self) -> None:
"""No grams and no serving falls back to the default portion."""
product = {"product_name": "P", "nutriments": {"energy-kcal_100g": 100}}
with _patch_get(_hits(product)):
result = off_candidates("p")[0]
assert result.grams == DEFAULT_PORTION_GRAMS
def test_blank_name_uses_description(self) -> None:
"""A blank product name falls back to the typed description."""
product = {"product_name": " ", "nutriments": {"energy-kcal_100g": 100}}
with _patch_get(_hits(product)):
result = off_candidates("my food")[0]
assert result.source == "openfoodfacts: my food"
def test_missing_macro_field_is_zero(self) -> None:
"""A product missing a macro records that macro as 0.0."""
product = {"product_name": "P", "nutriments": {"energy-kcal_100g": 100}}
with _patch_get(_hits(product)):
result = off_candidates("p")[0]
assert result.protein_g == 0.0
def test_request_exception_returns_empty(self) -> None:
"""A network failure degrades to an empty candidate list."""
with patch.object(
_estimator.requests,
"get",
side_effect=requests.RequestException("boom"),
):
assert off_candidates("x") == []
class TestEstimateOff:
"""The single-best-match convenience wrapper."""
def test_returns_top(self) -> None:
"""The top candidate is returned when one exists."""
with _patch_get(_hits(_GOOD)):
assert estimate_off("big mac", None) is not None
def test_none_when_empty(self) -> None:
"""No matches -> None."""
with _patch_get(_hits()):
assert estimate_off("nothing", None) is None
class TestEstimate:
"""The top-level estimate dispatcher."""
def test_manual_takes_precedence(self) -> None:
"""A manual kcal value skips Open Food Facts entirely."""
result = estimate("anything", manual_kcal=222)
assert result is not None
assert result.source == "manual"
def test_falls_back_to_off(self) -> None:
"""With no manual value, OFF is queried."""
with _patch_get(_hits(_GOOD)):
result = estimate("big mac")
assert result is not None
assert "openfoodfacts" in result.source
def test_nutrition_is_immutable() -> None:
"""The Nutrition value object is frozen (a dataclass safety check)."""
nutrition = Nutrition(1, 2, 3, 4, 5, "x")
assert nutrition.kcal == 1

View File

@ -0,0 +1,206 @@
"""Tests for _foodbank.py — the local corpus of previously logged foods.
The food-bank file is redirected into ``tmp_path`` by the autouse conftest
fixture, so every read/write here is isolated from real user data.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
from python_pkg.diet_guard import _foodbank
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._foodbank import (
lookup_food,
remember_food,
remember_meal,
search_foods,
)
from python_pkg.diet_guard._meal import MealItem
_NUT = Nutrition(
kcal=250,
protein_g=12,
carbs_g=30,
fat_g=10,
grams=200,
source="manual",
)
def _write_raw(bank: object) -> None:
"""Write an arbitrary object as the bank file (for defensive-read tests)."""
_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."""
def test_blank_description_ignored(self) -> None:
"""A blank name is not stored."""
remember_food(" ", _NUT)
assert lookup_food(" ") is None
def test_roundtrip_case_insensitive(self) -> None:
"""A remembered food is found regardless of case."""
remember_food("Big Mac", _NUT)
found = lookup_food("big mac")
assert found is not None
assert found.kcal == 250
assert found.source == "food bank"
def test_lookup_miss(self) -> None:
"""An unknown food looks up to None."""
assert lookup_food("nope") is None
def test_recording_twice_bumps_count(self) -> None:
"""Re-logging a food increments its use count (raises its ranking)."""
remember_food("oats", _NUT)
remember_food("oats", _NUT)
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert bank["oats"]["count"] == 2
class TestReadDefensive:
"""The bank read tolerates a missing or corrupt file."""
def test_missing_file(self) -> None:
"""No file yet -> empty results."""
assert search_foods("anything") == []
def test_corrupt_json(self) -> None:
"""Unparsable content -> empty bank."""
_foodbank.FOOD_BANK_FILE.write_text("not json", encoding="utf-8")
assert search_foods("x") == []
def test_top_level_not_dict(self) -> None:
"""A non-object top level -> empty bank."""
_write_raw([1, 2, 3])
assert search_foods("x") == []
def test_non_dict_records_filtered(self) -> None:
"""Records that are not objects are dropped on read."""
_write_raw({"good": {"desc": "good", "kcal": 5, "count": 1}, "bad": 123})
names = [name for name, _ in search_foods("")]
assert names == ["good"]
class TestSearch:
"""Ranked autocomplete search."""
def test_empty_query_ranks_by_count(self) -> None:
"""An empty query returns all foods, most-logged first."""
remember_food("rare", _NUT)
remember_food("common", _NUT)
remember_food("common", _NUT)
names = [name for name, _ in search_foods("")]
assert names[0] == "common"
def test_substring_match(self) -> None:
"""A substring of a stored name matches it."""
remember_food("chicken breast", _NUT)
names = [name for name, _ in search_foods("breast")]
assert "chicken breast" in names
def test_typo_within_threshold(self) -> None:
"""A close typo still matches via the fuzzy scorer."""
remember_food("chicken", _NUT)
names = [name for name, _ in search_foods("chiken")]
assert "chicken" in names
def test_below_threshold_filtered(self) -> None:
"""A wildly different query returns nothing."""
remember_food("chicken", _NUT)
assert search_foods("xylophone") == []
def test_display_name_falls_back_to_key(self) -> None:
"""A record with no usable desc displays under its key."""
_write_raw({"applekey": {"kcal": 50, "count": 1}})
names = [name for name, _ in search_foods("")]
assert names == ["applekey"]
class TestRememberMeal:
"""Banking a composite meal and its components."""
def test_banks_each_item_and_the_composite(self) -> None:
"""Every component and the summed meal land in the bank."""
items = [
MealItem("salad", Nutrition(80, 2, 8, 5, 120, "manual")),
MealItem("chicken", Nutrition(330, 62, 0, 7, 200, "manual")),
]
total = remember_meal("dinner", items)
assert total.kcal == 410
assert lookup_food("salad") is not None
assert lookup_food("chicken") is not None
dinner = lookup_food("dinner")
assert dinner is not None
assert dinner.kcal == 410
def test_composite_records_components(self) -> None:
"""The meal entry carries its component names for later use."""
item = MealItem("rice", Nutrition(260, 5, 56, 1, 180, "manual"))
remember_meal("bowl", [item])
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert bank["bowl"]["components"] == ["rice"]
def test_blank_name_banks_items_only(self) -> None:
"""A blank meal name still banks items but stores no empty composite."""
item = MealItem("toast", Nutrition(120, 4, 20, 2, 40, "manual"))
remember_meal(" ", [item])
assert lookup_food("toast") is not None
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert list(bank) == ["toast"]
class TestCorruptQuarantine:
"""A corrupt bank is moved aside, not re-warned about or overwritten."""
def test_corrupt_file_is_moved_aside(self) -> None:
"""Reading a corrupt bank quarantines it and returns empty."""
_foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8")
assert _foodbank._read_bank() == {}
assert not _foodbank.FOOD_BANK_FILE.exists()
backups = list(
_foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*"),
)
assert len(backups) == 1
assert backups[0].read_text(encoding="utf-8") == "{ broken"
def test_subsequent_reads_silent_and_empty(self) -> None:
"""After quarantine the next reads find no file (no warning flood)."""
_foodbank.FOOD_BANK_FILE.write_text("nope", encoding="utf-8")
assert _foodbank._read_bank() == {}
assert _foodbank._read_bank() == {}
assert _foodbank._read_bank() == {}
def test_corrupt_then_remember_starts_fresh(self) -> None:
"""A new entry after corruption writes a fresh bank, losing nothing."""
_foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8")
remember_food("eggs", _NUT)
assert lookup_food("eggs") is not None
assert list(_foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*"))
def test_rename_failure_is_handled(self) -> None:
"""If the corrupt file cannot be moved, the read still returns empty."""
_foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8")
with patch.object(Path, "rename", side_effect=OSError("locked")):
assert _foodbank._read_bank() == {}

View File

@ -0,0 +1,46 @@
"""Tests for _fuzzy.py — token-aware fuzzy matching.
Covers both the substring fast path and the per-word token scorer, including
the degenerate empty-input branch that falls back to a whole-string ratio.
"""
from __future__ import annotations
from python_pkg.diet_guard._fuzzy import match_score, token_score
class TestTokenScore:
"""The per-word best-match averaging scorer."""
def test_empty_query_falls_back_to_ratio(self) -> None:
"""An empty query has no words, so a whole-string ratio is used (0.0)."""
assert token_score("", "apple") == 0.0
def test_empty_name_falls_back_to_ratio(self) -> None:
"""An empty name has no words, so the ratio path runs."""
assert token_score("apple", "") == 0.0
def test_perfect_word_match(self) -> None:
"""Identical single words score 1.0."""
assert token_score("apple", "apple") == 1.0
def test_typo_word_scores_high(self) -> None:
"""A near-miss word (beast/breast) scores well above the 0.6 bar."""
assert token_score("beast", "breast") > 0.8
def test_multiword_averages_best_per_word(self) -> None:
"""Each query word takes its best name word; the mean is in (0, 1)."""
score = token_score("grilled chicken", "chicken breast")
assert 0.0 < score < 1.0
class TestMatchScore:
"""Substring containment first, then the token scorer."""
def test_substring_beats_one(self) -> None:
"""A contained query scores above 1.0 (1 + coverage fraction)."""
assert match_score("breast", "chicken breast") > 1.0
def test_non_substring_uses_token_score(self) -> None:
"""A typo that is not a substring routes to the token scorer (< 1.0)."""
assert match_score("beast", "breast") == token_score("beast", "breast")

View File

@ -0,0 +1,79 @@
"""Tests for _gate.py — the slot/state composition that decides locking.
The slot arithmetic and the logged-slot state are both exercised elsewhere, so
here the logged set is mocked and ``now`` is injected to drive each decision.
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import patch
from python_pkg.diet_guard._gate import due_slots, gate_is_due, gate_message
def _at(hour: int) -> datetime:
"""Return a fixed local datetime at ``hour``."""
return datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc)
def _logged(slots: set[int]) -> object:
"""Patch the logged-slots source so the decision is deterministic."""
return patch(
"python_pkg.diet_guard._gate.logged_slots_today",
return_value=slots,
)
class TestDueSlots:
"""Elapsed-but-unlogged slots."""
def test_injected_now(self) -> None:
"""With 08:00 logged at 13:00, only 12:00 is due."""
with _logged({8}):
assert due_slots(_at(13)) == (12,)
def test_default_now_uses_clock(self) -> None:
"""Omitting ``now`` reads the real clock (mocked here for determinism)."""
with (
_logged(set()),
patch(
"python_pkg.diet_guard._gate.now_local",
return_value=_at(9),
),
):
assert due_slots() == (8,)
class TestGateIsDue:
"""The boolean lock decision."""
def test_due_when_a_slot_is_missing(self) -> None:
"""A missing elapsed slot warrants a lock."""
with _logged(set()):
assert gate_is_due(_at(13)) is True
def test_not_due_when_all_logged(self) -> None:
"""Everything elapsed is logged -> no lock."""
with _logged({8, 12}):
assert gate_is_due(_at(13)) is False
class TestGateMessage:
"""The human-readable reason line."""
def test_all_logged(self) -> None:
"""Nothing missing -> the up-to-date message."""
with _logged({8, 12}):
assert "up to date" in gate_message(_at(13))
def test_single_missing(self) -> None:
"""One missing slot -> singular phrasing."""
with _logged({8}):
assert gate_message(_at(13)) == "Log your 12:00 meal to unlock."
def test_multiple_missing(self) -> None:
"""Several missing slots -> plural phrasing listing them."""
with _logged(set()):
message = gate_message(_at(17))
assert message == "Log your meals for 08:00, 12:00, 16:00 to unlock."

View File

@ -0,0 +1,917 @@
"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window.
A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
real ``TclError``) replaces the conftest's blanket MagicMock for the duration of
each gate test, so the window's *logic* runs for real against in-memory widgets
without ever opening a window or grabbing the keyboard.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.diet_guard import _gatelock
from python_pkg.diet_guard._budget import seal_budget
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._gatelock import (
MealGate,
_format_preview,
_pending_slots,
_safe_float,
acquire_gate_lock,
release_gate_lock,
wait_for_display,
)
from python_pkg.diet_guard._meal import MealItem
# Captured before any autouse fixture patches the module attribute, so the real
# class (not the conftest MagicMock) is available for its callback-error test.
_REAL_GATE_ROOT = _gatelock._GateRoot
class _FakeTclError(Exception):
"""Stand-in for ``tkinter.TclError`` (a real, catchable exception)."""
class FakeVar:
"""A functional ``StringVar``: stores and returns a string."""
def __init__(self, master: object = None, value: str = "") -> None:
self._value = value
def get(self) -> str:
return self._value
def set(self, value: str) -> None:
self._value = value
class FakeEntry:
"""A functional one-line entry (delete clears, insert appends)."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._value = ""
def get(self) -> str:
return self._value
def delete(self, first: object, last: object = None) -> None:
self._value = ""
def insert(self, index: object, text: str) -> None:
self._value += text
def pack(self, *args: object, **kwargs: object) -> FakeEntry:
return self
def bind(self, *args: object, **kwargs: object) -> None:
pass
def configure(self, *args: object, **kwargs: object) -> None:
pass
config = configure
def focus_set(self) -> None:
pass
def focus_force(self) -> None:
pass
class FakeText(FakeEntry):
"""A functional multi-line text box (``get`` ignores the index range)."""
def get(self, start: object = None, end: object = None) -> str:
return self._value
class FakeListbox:
"""A functional listbox tracking items and the current selection."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._items: list[str] = []
self._sel: tuple[int, ...] = ()
def delete(self, first: object, last: object = None) -> None:
self._items = []
def insert(self, index: object, text: str) -> None:
self._items.append(text)
def curselection(self) -> tuple[int, ...]:
return self._sel
def selection_set(self, index: int) -> None:
self._sel = (index,)
def selection_clear(self, first: object, last: object = None) -> None:
self._sel = ()
def pack(self, *args: object, **kwargs: object) -> FakeListbox:
return self
def bind(self, *args: object, **kwargs: object) -> None:
pass
class FakeWidget:
"""A generic no-op widget for Frame/Label/Button/OptionMenu."""
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
return self
def place(self, *args: object, **kwargs: object) -> FakeWidget:
return self
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
return self
config = configure
def bind(self, *args: object, **kwargs: object) -> None:
pass
_FAKE_TK = SimpleNamespace(
END="end",
TclError=_FakeTclError,
StringVar=FakeVar,
Frame=FakeWidget,
Label=FakeWidget,
Button=FakeWidget,
OptionMenu=FakeWidget,
Entry=FakeEntry,
Text=FakeText,
Listbox=FakeListbox,
Event=object,
)
@pytest.fixture
def gate() -> object:
"""Build a demo gate whose widgets are functional fakes."""
with patch.object(_gatelock, "tk", _FAKE_TK):
yield MealGate(demo_mode=True)
def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition:
"""A simple reference nutrition for driving the form."""
return Nutrition(kcal, 10, 20, 5, grams, "food bank")
# --------------------------------------------------------------------------
# Module-level helpers
# --------------------------------------------------------------------------
class TestModuleHelpers:
"""Pure functions and the single-instance lock."""
def test_safe_float_blank(self) -> None:
"""A blank string is None."""
assert _safe_float("") is None
def test_safe_float_number(self) -> None:
"""A numeric string parses."""
assert _safe_float("3.5") == 3.5
def test_safe_float_non_numeric(self) -> None:
"""A non-numeric string is None."""
assert _safe_float("abc") is None
def test_format_preview_with_portion(self) -> None:
"""A non-zero portion shows the grams segment."""
text = _format_preview(_nutrition(grams=200))
assert "200g" in text
def test_format_preview_without_portion(self) -> None:
"""A zero portion omits the grams segment."""
text = _format_preview(_nutrition(grams=0))
assert "g ·" not in text
def test_gate_lock_single_instance(self) -> None:
"""A second acquire while the first is held returns None."""
first = acquire_gate_lock()
assert first is not None
assert acquire_gate_lock() is None
release_gate_lock(first)
def test_pending_slots_due(self) -> None:
"""When slots are due, those are returned verbatim."""
with patch.object(_gatelock, "due_slots", return_value=[12, 16]):
assert _pending_slots(demo_mode=False) == [12, 16]
def test_pending_slots_demo_fallback(self) -> None:
"""Demo mode invents a representative slot when nothing is due."""
with patch.object(_gatelock, "due_slots", return_value=[]):
assert len(_pending_slots(demo_mode=True)) == 1
def test_pending_slots_production_empty(self) -> None:
"""Production with nothing due returns no slots."""
with patch.object(_gatelock, "due_slots", return_value=[]):
assert not _pending_slots(demo_mode=False)
class TestAssertNotUnderPytest:
"""The safety net that blocks a real Tk gate under pytest."""
def test_raises_with_real_tkinter(self) -> None:
"""Real tkinter under pytest is refused."""
with (
patch.object(_gatelock, "tk", SimpleNamespace(__name__="tkinter")),
pytest.raises(RuntimeError),
):
_gatelock._assert_not_under_pytest()
def test_passes_with_mock(self) -> None:
"""A mocked tk (name != tkinter) passes straight through."""
with patch.object(_gatelock, "tk", SimpleNamespace(__name__="mock")):
_gatelock._assert_not_under_pytest()
class TestGateRootCallback:
"""The root's callback-exception routing."""
def test_routes_to_handler(self) -> None:
"""A set handler is invoked on a callback error."""
root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT)
root.on_callback_error = MagicMock()
_REAL_GATE_ROOT.report_callback_exception(
root, ValueError, ValueError("x"), None
)
root.on_callback_error.assert_called_once()
def test_no_handler_is_safe(self) -> None:
"""With no handler set, the error is just logged."""
root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT)
root.on_callback_error = None
_REAL_GATE_ROOT.report_callback_exception(
root, ValueError, ValueError("x"), None
)
# --------------------------------------------------------------------------
# Construction
# --------------------------------------------------------------------------
class TestConstruction:
"""Building the window in both modes."""
def test_demo_builds(self, gate: MealGate) -> None:
"""A demo gate constructs with a pending slot and grams basis."""
assert gate.demo_mode is True
assert gate._unit.get() == "grams"
def test_production_builds(self) -> None:
"""A production gate disables VT switching and grabs input."""
with (
patch.object(_gatelock, "tk", _FAKE_TK),
patch.object(_gatelock.shutil, "which", return_value=None),
):
gate = MealGate(demo_mode=False)
assert gate.demo_mode is False
# --------------------------------------------------------------------------
# Form logic
# --------------------------------------------------------------------------
class TestFormBasics:
"""Field helpers and the numeric validator."""
def test_numeric_validator(self, gate: MealGate) -> None:
"""Blank and numbers are allowed; words are not."""
assert gate._is_numeric_or_blank("")
assert gate._is_numeric_or_blank("12.5")
assert not gate._is_numeric_or_blank("abc")
def test_desc_get_set(self, gate: MealGate) -> None:
"""The description round-trips through its helpers, trimmed."""
gate._set_desc(" shoarma ")
assert gate._get_desc() == "shoarma"
def test_desc_return_suppresses_newline(self, gate: MealGate) -> None:
"""Enter in the description submits and returns the break sentinel."""
gate._set_desc("apple")
with patch.object(gate, "_on_submit") as submit:
assert gate._on_desc_return(None) == "break"
submit.assert_called_once()
def test_macro_values_non_numeric(self, gate: MealGate) -> None:
"""A non-numeric macro field makes the whole read None."""
gate._kcal_entry.insert(0, "abc")
assert gate._macro_values() is None
class TestReferenceModel:
"""The reference -> total nutrition computation."""
def test_reference_none_without_calories(self, gate: MealGate) -> None:
"""No calories typed means no reference yet."""
assert gate._reference_nutrition() is None
def test_current_is_reference_without_amount(self, gate: MealGate) -> None:
"""With calories but no amount, the reference stands in as the total."""
gate._kcal_entry.insert(0, "200")
current = gate._current_nutrition()
assert current is not None
assert current.kcal == 200
def test_current_scales_with_amount(self, gate: MealGate) -> None:
"""Grams eaten scale the per-100 g reference into the total."""
gate._kcal_entry.insert(0, "200")
gate._amount_entry.insert(0, "200")
current = gate._current_nutrition()
assert current is not None
assert current.kcal == 400
class TestSuggestions:
"""Autocomplete population and selection."""
def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None:
"""In items mode, typing a staple fills the per-item weight."""
gate._unit.set("items")
gate._set_desc("apple")
gate._on_desc_keyrelease(None)
assert gate._per_entry.get() == "182"
def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None:
"""Picking a banked suggestion adopts its name and macros."""
gate._suggestions = [("apple pie", _nutrition(300, 120))]
gate._suggestion_mode = "bank"
gate._suggestion_box.selection_set(0)
gate._on_suggestion_select(None)
assert gate._get_desc() == "apple pie"
assert gate._kcal_entry.get() == "300"
def test_select_candidate_keeps_description(self, gate: MealGate) -> None:
"""An OFF candidate fills macros but not the typed description."""
gate._set_desc("my dish")
gate._suggestions = [("openfoodfacts: X", _nutrition(250, 100))]
gate._suggestion_mode = "candidates"
gate._suggestion_box.selection_set(0)
gate._on_suggestion_select(None)
assert gate._get_desc() == "my dish"
def test_select_no_selection(self, gate: MealGate) -> None:
"""No selection is a no-op."""
gate._on_suggestion_select(None)
def test_select_out_of_range(self, gate: MealGate) -> None:
"""A stale selection index beyond the list is ignored."""
gate._suggestions = []
gate._suggestion_box.selection_set(5)
gate._on_suggestion_select(None)
class TestUnitToggle:
"""Switching the grams/items basis."""
def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None:
"""A picked food is re-expressed per item, then back per 100 g."""
gate._apply_reference(_nutrition(52, 100), name="apple")
gate._unit.set("items")
gate._on_unit_change("items")
per_item = gate._kcal_entry.get()
gate._unit.set("grams")
gate._on_unit_change("grams")
assert gate._kcal_entry.get() == "52"
assert per_item != "52"
def test_toggle_without_reference_clears(self, gate: MealGate) -> None:
"""With no picked food, a toggle clears the macro fields."""
gate._kcal_entry.insert(0, "123")
gate._last_reference = None
gate._unit.set("items")
gate._on_unit_change("items")
assert gate._kcal_entry.get() == ""
def test_macro_edit_drops_reference(self, gate: MealGate) -> None:
"""Hand-editing a macro invalidates the stored reference."""
gate._last_reference = _nutrition()
gate._on_macro_edit(None)
assert gate._last_reference is None
class TestSubmit:
"""The two-step submit (look up, then log)."""
def test_empty_description(self, gate: MealGate) -> None:
"""Submitting with no description prompts for one."""
gate._on_submit()
assert "Type what you ate" in gate._status.get()
def test_non_numeric_macros(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before logging."""
gate._set_desc("apple")
gate._kcal_entry.insert(0, "abc")
gate._on_submit()
assert "must be numbers" in gate._status.get()
def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None:
"""A blank calorie field looks the food up rather than logging."""
gate._set_desc("apple")
with patch.object(gate, "_begin_lookup") as lookup:
gate._on_submit()
lookup.assert_called_once()
def test_defensive_none_nutrition(self, gate: MealGate) -> None:
"""A calorie value but unresolvable nutrition prompts again (guard)."""
gate._set_desc("apple")
gate._kcal_entry.insert(0, "200")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_submit()
assert "Enter the calories" in gate._status.get()
def test_valid_submit_records(self, gate: MealGate) -> None:
"""A described, priced meal is recorded."""
gate._set_desc("apple")
gate._kcal_entry.insert(0, "95")
with patch.object(gate, "_record") as record:
gate._on_submit()
record.assert_called_once()
def test_on_return_submits(self, gate: MealGate) -> None:
"""Enter in a numeric field submits."""
with patch.object(gate, "_on_submit") as submit:
gate._on_return(None)
submit.assert_called_once()
class TestLookup:
"""Step one: filling the form from a lookup."""
def test_no_candidates(self, gate: MealGate) -> None:
"""No match asks for a manual value."""
gate._set_desc("nonsense")
with patch.object(_gatelock, "lookup_candidates", return_value=[]):
gate._begin_lookup("nonsense")
assert "Couldn't look that up" in gate._status.get()
def test_single_candidate(self, gate: MealGate) -> None:
"""A single match fills the fields and invites review."""
with patch.object(
_gatelock,
"lookup_candidates",
return_value=[("apple", _nutrition(95, 100))],
):
gate._begin_lookup("apple")
assert "Review the values" in gate._status.get()
def test_multiple_candidates(self, gate: MealGate) -> None:
"""Several matches invite picking another."""
with patch.object(
_gatelock,
"lookup_candidates",
return_value=[
("a", _nutrition(95, 100)),
("b", _nutrition(120, 100)),
],
):
gate._begin_lookup("apple")
assert "pick another" in gate._status.get()
class TestRecord:
"""Logging a meal and advancing the slot walk."""
def test_demo_logs_without_slot(self, gate: MealGate) -> None:
"""A demo record banks the food but tags no real slot."""
gate._pending = [8]
with patch.object(_gatelock, "log_meal") as log:
gate._record("apple", _nutrition(95, 100))
assert log.call_args.args[2] is None
def test_last_slot_unlocks(self, gate: MealGate) -> None:
"""Recording the final pending slot triggers the unlock."""
gate._pending = [8]
with (
patch.object(_gatelock, "log_meal"),
patch.object(_gatelock, "remember_food"),
patch.object(gate, "_unlock") as unlock,
):
gate._record("apple", _nutrition(95, 100))
unlock.assert_called_once()
def test_more_slots_continue(self, gate: MealGate) -> None:
"""With slots remaining, the form clears and prompts the next."""
gate._pending = [8, 12]
with (
patch.object(_gatelock, "log_meal"),
patch.object(_gatelock, "remember_food"),
):
gate._record("apple", _nutrition(95, 100))
assert gate._pending == [12]
assert "next meal" in gate._status.get()
def test_unlock_schedules_close(self, gate: MealGate) -> None:
"""Unlock sets the closing status and schedules teardown."""
gate._unlock("logged X")
assert "unlocking" in gate._status.get()
class TestDashboard:
"""The running calorie/macro panel."""
def test_headline_with_budget(self, gate: MealGate) -> None:
"""A sealed budget shows consumed/target/remaining."""
seal_budget(2000)
gate._refresh_dashboard()
assert "left" in gate._cal_headline.get()
def test_headline_without_budget(self, gate: MealGate) -> None:
"""With no budget, only today's total is shown."""
gate._refresh_dashboard()
assert "kcal today" in gate._cal_headline.get()
def test_dashboard_lists_entries(self, gate: MealGate) -> None:
"""Logged entries appear in the detail panel."""
seal_budget(2000, weight_kg=80)
_gatelock.log_meal("apple", _nutrition(95, 100), 8)
gate._refresh_dashboard()
text = gate._dashboard.get()
assert "apple" in text
assert "protein" in text
def test_dashboard_empty(self, gate: MealGate) -> None:
"""With nothing logged, the panel says so."""
gate._refresh_dashboard()
assert "nothing logged yet" in gate._dashboard.get()
def test_slot_header_variants(self, gate: MealGate) -> None:
"""The header covers none / one / several pending slots."""
gate._pending = []
gate._refresh_slot_header()
assert "All meals logged" in gate._slot_header.get()
gate._pending = [8]
gate._refresh_slot_header()
assert "Log your" in gate._slot_header.get()
gate._pending = [8, 12]
gate._refresh_slot_header()
assert "remaining" in gate._slot_header.get()
def test_projection_with_budget(self, gate: MealGate) -> None:
"""The projection shows the after-this-item remaining when priced."""
seal_budget(2000)
gate._kcal_entry.insert(0, "300")
gate._refresh_projection()
assert "after this item" in gate._projection.get()
class TestWindowMechanics:
"""VT switching, grabbing, signals, and teardown."""
def test_disable_vt_no_tool(self, gate: MealGate) -> None:
"""A missing setxkbmap leaves VT switching enabled."""
with patch.object(_gatelock.shutil, "which", return_value=None):
gate._disable_vt_switching()
assert gate._vt_disabled is False
def test_disable_and_restore_vt(self, gate: MealGate) -> None:
"""With the tool present, VT switching toggles off then back on."""
with (
patch.object(_gatelock.shutil, "which", return_value="/x/setxkbmap"),
patch.object(_gatelock.subprocess, "run") as run,
):
gate._disable_vt_switching()
assert gate._vt_disabled is True
gate._restore_vt_switching()
assert gate._vt_disabled is False
assert run.call_count == 2
def test_restore_when_not_disabled(self, gate: MealGate) -> None:
"""Restoring when never disabled is a no-op."""
gate._vt_disabled = False
gate._restore_vt_switching()
def test_grab_success(self, gate: MealGate) -> None:
"""A successful grab focuses the first field."""
gate.root.grab_set_global = MagicMock()
gate._acquire_global_grab(attempt=1)
def test_grab_retries_on_conflict(self, gate: MealGate) -> None:
"""A held grab reschedules another attempt instead of giving up."""
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
gate.root.after = MagicMock()
gate._acquire_global_grab(attempt=_gatelock._GRAB_LOG_EVERY)
gate.root.after.assert_called_once()
def test_focus_first_field(self, gate: MealGate) -> None:
"""Focusing the first field is safe."""
gate._focus_first_field()
def test_keepalive_rearms(self, gate: MealGate) -> None:
"""The keepalive reschedules itself."""
gate.root.after = MagicMock()
gate._keepalive()
gate.root.after.assert_called_once()
def test_signal_restores_and_exits(self, gate: MealGate) -> None:
"""A termination signal restores VT switching and exits."""
with pytest.raises(SystemExit):
gate._on_signal(15, None)
def test_run_installs_and_loops(self, gate: MealGate) -> None:
"""run wires handlers, starts the loop, and restores on exit."""
gate.root.mainloop = MagicMock()
with (
patch.object(_gatelock.signal, "signal"),
patch.object(_gatelock.atexit, "register"),
):
gate.run()
gate.root.mainloop.assert_called_once()
def test_close(self, gate: MealGate) -> None:
"""Close restores VT switching and destroys the window."""
gate.root.destroy = MagicMock()
gate.close()
gate.root.destroy.assert_called_once()
def test_callback_error_status(self, gate: MealGate) -> None:
"""An unexpected callback error surfaces a recoverable message."""
gate._handle_callback_error()
assert "went wrong" in gate._status.get()
def test_restore_vt_without_tool(self, gate: MealGate) -> None:
"""Restoring when the tool has since vanished still clears the flag."""
gate._vt_disabled = True
with patch.object(_gatelock.shutil, "which", return_value=None):
gate._restore_vt_switching()
assert gate._vt_disabled is False
def test_grab_retry_without_log(self, gate: MealGate) -> None:
"""An early blocked attempt reschedules without logging."""
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
gate.root.after = MagicMock()
gate._acquire_global_grab(attempt=1)
gate.root.after.assert_called_once()
class TestBasisAndAmount:
"""Edge branches in the grams/items basis and amount maths."""
def test_basis_typed_value(self, gate: MealGate) -> None:
"""A typed per-value is honoured directly."""
gate._set_entry(gate._per_entry, "50")
assert gate._basis_grams() == 50
def test_basis_items_known_staple(self, gate: MealGate) -> None:
"""Items mode with a blank per falls back to the staple weight."""
gate._per_entry.delete(0)
gate._unit.set("items")
gate._set_desc("apple")
assert gate._basis_grams() == 182
def test_basis_items_unknown(self, gate: MealGate) -> None:
"""An unknown item uses the default piece weight."""
gate._per_entry.delete(0)
gate._unit.set("items")
gate._set_desc("mystery")
assert gate._basis_grams() == _gatelock.DEFAULT_ITEM_GRAMS
def test_basis_grams_default(self, gate: MealGate) -> None:
"""Grams mode with a blank per uses the per-100 g default."""
gate._per_entry.delete(0)
assert gate._basis_grams() == _gatelock._DEFAULT_PER_GRAMS
def test_eaten_grams_none(self, gate: MealGate) -> None:
"""No amount typed yields no eaten weight."""
assert gate._eaten_grams() is None
def test_eaten_grams_items(self, gate: MealGate) -> None:
"""Items mode multiplies the count by the per-item weight."""
gate._unit.set("items")
gate._set_desc("apple")
gate._set_entry(gate._per_entry, "182")
gate._set_entry(gate._amount_entry, "5")
assert gate._eaten_grams() == 5 * 182
def test_amount_change_refreshes(self, gate: MealGate) -> None:
"""Changing the amount recomputes the preview."""
gate._set_entry(gate._kcal_entry, "100")
gate._set_entry(gate._amount_entry, "200")
gate._on_amount_change(None)
assert gate._preview.get()
def test_projection_else_without_item(self, gate: MealGate) -> None:
"""With a budget but no priced item, no after-this-item is shown."""
seal_budget(2000)
gate._refresh_projection()
text = gate._projection.get()
assert "left" in text
assert "after this item" not in text
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
"""In grams mode the per-item weight is not touched on keyrelease."""
gate._unit.set("grams")
gate._set_desc("apple")
gate._on_desc_keyrelease(None)
def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
"""An unknown item in items mode leaves the per field unchanged."""
gate._unit.set("items")
gate._set_desc("zzzz")
gate._on_desc_keyrelease(None)
def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
"""A grams-mode pick does not overwrite an amount already typed."""
gate._set_entry(gate._amount_entry, "50")
gate._apply_reference(_nutrition(100, 100))
assert gate._amount_entry.get() == "50"
class TestDisplayReadiness:
"""The session-start display wait that absorbs the X auth-cookie race."""
def test_ready_when_root_connects(self) -> None:
"""A Tk root that builds and destroys cleanly means the display is up."""
fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError)
with patch.object(_gatelock, "tk", fake_tk):
assert _gatelock._display_is_ready() is True
fake_tk.Tk.return_value.destroy.assert_called_once()
def test_not_ready_on_tclerror(self) -> None:
"""A TclError from Tk() (no display / no cookie yet) means not ready."""
fake_tk = SimpleNamespace(
Tk=MagicMock(side_effect=_FakeTclError("no display")),
TclError=_FakeTclError,
)
with patch.object(_gatelock, "tk", fake_tk):
assert _gatelock._display_is_ready() is False
def test_wait_returns_immediately_when_ready(self) -> None:
"""A display ready on the first probe returns at once and never sleeps."""
sleep = MagicMock()
with patch.object(_gatelock, "_display_is_ready", return_value=True):
ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0))
assert ready is True
sleep.assert_not_called()
def test_wait_polls_then_succeeds(self) -> None:
"""Not-ready then ready sleeps once between probes, then unblocks."""
sleep = MagicMock()
monotonic = MagicMock(side_effect=[0.0, 0.0])
with patch.object(_gatelock, "_display_is_ready", side_effect=[False, True]):
assert wait_for_display(sleep=sleep, monotonic=monotonic) is True
sleep.assert_called_once()
def test_wait_times_out_and_defers(self) -> None:
"""A display still down at the deadline gives up so the next tick retries."""
sleep = MagicMock()
monotonic = MagicMock(side_effect=[0.0, 60.0])
with patch.object(_gatelock, "_display_is_ready", return_value=False):
assert wait_for_display(sleep=sleep, monotonic=monotonic) is False
sleep.assert_not_called()
class TestMealFlow:
"""Building and logging a multi-item composite meal."""
def test_meal_name_trimmed(self, gate: MealGate) -> None:
"""The meal name is read back trimmed."""
gate._meal_name_entry.insert(0, " dinner ")
assert gate._meal_name() == "dinner"
def test_summary_empty_with_no_items(self, gate: MealGate) -> None:
"""With no accumulated items the running summary is blank."""
gate._refresh_meal_summary()
assert gate._meal_summary.get() == ""
def test_summary_lists_items_and_total(self, gate: MealGate) -> None:
"""The summary shows the item names and the running calorie total."""
gate._meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
gate._refresh_meal_summary()
summary = gate._meal_summary.get()
assert "salad, chicken" in summary
assert "410 kcal" in summary
def test_add_item_requires_description(self, gate: MealGate) -> None:
"""Adding with no description prompts for one."""
gate._on_add_item()
assert "Type the item first" in gate._status.get()
def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before adding."""
gate._set_desc("salad")
gate._kcal_entry.insert(0, "abc")
gate._on_add_item()
assert "must be numbers" in gate._status.get()
def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None:
"""A blank calorie field looks the item up rather than adding."""
gate._set_desc("salad")
with patch.object(gate, "_begin_lookup") as lookup:
gate._on_add_item()
lookup.assert_called_once()
def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None:
"""A priced item that will not resolve prompts again (guard)."""
gate._set_desc("salad")
gate._kcal_entry.insert(0, "80")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_add_item()
assert "add the item" in gate._status.get()
def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None:
"""A valid item is appended, the form clears, the meal name is kept."""
gate._meal_name_entry.insert(0, "dinner")
gate._set_desc("salad")
gate._kcal_entry.insert(0, "80")
gate._on_add_item()
assert len(gate._meal_items) == 1
assert gate._meal_items[0].name == "salad"
assert gate._get_desc() == ""
assert gate._meal_name() == "dinner"
assert "Added salad" in gate._status.get()
def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None:
"""Submitting an empty form with items finalizes the meal."""
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
log_meal_.assert_called_once()
def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None:
"""A filled form plus existing items adds the form item, then logs."""
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._set_desc("rice")
gate._kcal_entry.insert(0, "260")
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
assert len(gate._meal_items) == 2
assert gate._meal_items[1].name == "rice"
log_meal_.assert_called_once()
def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None:
"""Logging a meal banks it under the typed name and advances the slot."""
gate._pending = [8, 12]
gate._meal_name_entry.insert(0, "dinner")
gate._meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
with (
patch.object(
_gatelock, "remember_meal", return_value=_nutrition(410, 320)
) as remember,
patch.object(_gatelock, "log_meal") as log,
):
gate._log_meal()
assert remember.call_args.args[0] == "dinner"
assert log.call_args.args[0] == "dinner"
assert gate._meal_items == []
assert gate._pending == [12]
def test_log_meal_uses_default_name(self, gate: MealGate) -> None:
"""A blank meal name falls back to the default."""
gate._pending = [8, 12]
gate._meal_items = [MealItem("soup", _nutrition(150, 300))]
with (
patch.object(
_gatelock, "remember_meal", return_value=_nutrition(150, 300)
) as remember,
patch.object(_gatelock, "log_meal"),
):
gate._log_meal()
assert remember.call_args.args[0] == _gatelock._DEFAULT_MEAL_NAME
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
"""A demo gate tags logs with no real slot."""
gate._pending = [8]
assert gate._slot_for_log() is None
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
"""A production gate tags logs with the current slot."""
gate.demo_mode = False
gate._pending = [12]
assert gate._slot_for_log() == 12
def test_clear_inputs_discards_meal(self, gate: MealGate) -> None:
"""Clearing between slots drops the in-progress meal and its name."""
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._meal_name_entry.insert(0, "dinner")
gate._meal_summary.set("something")
gate._clear_inputs()
assert gate._meal_items == []
assert gate._meal_name() == ""
assert gate._meal_summary.get() == ""
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
"""Finishing the final slot triggers unlock."""
gate._pending = [20]
with patch.object(gate, "_unlock") as unlock:
gate._finish_slot("done")
unlock.assert_called_once()

View File

@ -0,0 +1,21 @@
"""Tests for the package entry points (__init__, __main__).
Importing ``__main__`` executes its module-level code (the ``if __name__`` guard
is excluded from coverage), wiring the ``python -m`` entry point under test.
"""
from __future__ import annotations
import importlib
def test_main_module_imports() -> None:
"""The ``python -m python_pkg.diet_guard`` entry module imports cleanly."""
module = importlib.import_module("python_pkg.diet_guard.__main__")
assert hasattr(module, "main")
def test_package_imports() -> None:
"""The package itself imports without side effects."""
package = importlib.import_module("python_pkg.diet_guard")
assert package is not None

View File

@ -0,0 +1,60 @@
"""Tests for _meal.py — composite-meal summing."""
from __future__ import annotations
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._meal import MEAL_SOURCE, MealItem, meal_total
def _item(
name: str,
kcal: float,
macros: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
) -> MealItem:
"""Build a MealItem from a name, calories, and a (protein, carbs, fat, grams)."""
protein, carbs, fat, grams = macros
return MealItem(
name,
Nutrition(
kcal=kcal,
protein_g=protein,
carbs_g=carbs,
fat_g=fat,
grams=grams,
source="manual",
),
)
class TestMealTotal:
"""Summing a meal's items."""
def test_sums_every_field(self) -> None:
"""Each macro, calories, and weight are added across the items."""
items = [
_item("salad", 80, (2, 8, 5, 120)),
_item("chicken", 330, (62, 0, 7, 200)),
_item("rice", 260, (5, 56, 1, 180)),
]
total = meal_total(items)
assert total.kcal == 670
assert total.protein_g == 69
assert total.carbs_g == 64
assert total.fat_g == 13
assert total.grams == 500
assert total.source == MEAL_SOURCE
def test_empty_is_zero(self) -> None:
"""An empty meal sums to an all-zero composite rather than raising."""
assert meal_total([]) == Nutrition(
kcal=0.0,
protein_g=0.0,
carbs_g=0.0,
fat_g=0.0,
grams=0.0,
source=MEAL_SOURCE,
)
def test_rounds_to_one_decimal(self) -> None:
"""Floating sums are rounded to 0.1, like the rest of the log."""
assert meal_total([_item("a", 0.1), _item("b", 0.2)]).kcal == 0.3

View File

@ -0,0 +1,65 @@
"""Tests for _portions.py — built-in staple weights and macros.
Covers the fuzzy staple match, the empty-input guard, and the per-100 g
Nutrition / suggestion builders.
"""
from __future__ import annotations
from python_pkg.diet_guard._portions import (
estimate_unit_grams,
staple_nutrition,
suggest_staples,
)
class TestEstimateUnitGrams:
"""One piece's typical weight."""
def test_known_staple(self) -> None:
"""A known staple returns its curated unit weight."""
assert estimate_unit_grams("apple") == 182.0
def test_fuzzy_plural(self) -> None:
"""A close variant (plural) still matches the staple."""
assert estimate_unit_grams("apples") == 182.0
def test_unknown_returns_none(self) -> None:
"""An unrecognised food has no known unit weight."""
assert estimate_unit_grams("quinoa risotto") is None
def test_empty_returns_none(self) -> None:
"""A blank description short-circuits to None."""
assert estimate_unit_grams(" ") is None
class TestStapleNutrition:
"""Per-100 g Nutrition for a staple."""
def test_known_staple_per_100g(self) -> None:
"""An egg resolves to its per-100 g macros at a 100 g basis."""
nutrition = staple_nutrition("egg")
assert nutrition is not None
assert nutrition.grams == 100.0
assert nutrition.source == "staple: egg"
def test_unknown_returns_none(self) -> None:
"""A non-staple resolves to None."""
assert staple_nutrition("beef wellington") is None
class TestSuggestStaples:
"""Live autocomplete over the staple table."""
def test_match(self) -> None:
"""A matching query surfaces the staple by name."""
names = [name for name, _ in suggest_staples("banana")]
assert "banana" in names
def test_empty_query(self) -> None:
"""A blank query suggests nothing."""
assert suggest_staples("") == []
def test_no_match(self) -> None:
"""A query matching no staple returns an empty list."""
assert suggest_staples("xyzzy") == []

View File

@ -0,0 +1,141 @@
"""Tests for _resolve.py — the manual/bank/staple/OFF resolution precedence.
Real food-bank and staple lookups are used (both isolated/offline); only the
Open Food Facts network layer is mocked, via the estimator it delegates to.
"""
from __future__ import annotations
from unittest.mock import patch
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._foodbank import remember_food
from python_pkg.diet_guard._resolve import (
ManualMacros,
lookup_candidates,
resolve_nutrition,
suggest_foods,
)
_OFF = Nutrition(260, 12, 30, 10, 100, "openfoodfacts: Big Mac")
class TestResolveManual:
"""A manual calorie value, scaled from its stated basis."""
def test_per_grams_scaled_to_eaten(self) -> None:
"""200 kcal per 100 g eaten as 330 g logs 660 kcal."""
result = resolve_nutrition(
"pasta",
grams=330,
manual_macros=ManualMacros(kcal=200, per_grams=100),
)
assert result is not None
assert result.kcal == 660.0
def test_no_basis_keeps_value(self) -> None:
"""With neither grams nor per-grams, the manual value is taken as-is."""
result = resolve_nutrition("shake", manual_macros=ManualMacros(kcal=180))
assert result is not None
assert result.kcal == 180.0
def test_grams_only_is_the_basis(self) -> None:
"""With grams but no per-grams, grams is the reference (no double scale)."""
result = resolve_nutrition(
"soup",
grams=250,
manual_macros=ManualMacros(kcal=300),
)
assert result is not None
assert result.kcal == 300.0
class TestResolveBankAndStaple:
"""Local sources, before any network call."""
def test_banked_food_scaled(self) -> None:
"""A banked food is rescaled to the amount eaten."""
remember_food("carbonara", Nutrition(700, 20, 80, 30, 350, "manual"))
result = resolve_nutrition("carbonara", grams=700)
assert result is not None
assert result.kcal == 1400.0
def test_banked_food_no_grams(self) -> None:
"""Without grams, the banked macros are returned unscaled."""
remember_food("carbonara", Nutrition(700, 20, 80, 30, 350, "manual"))
result = resolve_nutrition("carbonara")
assert result is not None
assert result.kcal == 700.0
def test_staple_before_off(self) -> None:
"""A bare staple resolves locally (and never hits OFF)."""
result = resolve_nutrition("apple", grams=200)
assert result is not None
assert "staple: apple" in result.source
def test_staple_no_grams(self) -> None:
"""A staple with no grams returns its per-100 g basis."""
result = resolve_nutrition("egg")
assert result is not None
assert result.grams == 100.0
def test_off_fallback(self) -> None:
"""An unknown, non-staple food falls through to Open Food Facts."""
with patch(
"python_pkg.diet_guard._resolve.estimate_off",
return_value=_OFF,
):
result = resolve_nutrition("exotic dish")
assert result is not None
assert "openfoodfacts" in result.source
class TestLookupCandidates:
"""Reviewable candidates for the blank-calorie gate path."""
def test_banked_candidate(self) -> None:
"""A banked food yields a single scaled candidate under its name."""
remember_food("oats", Nutrition(380, 13, 67, 7, 100, "manual"))
candidates = lookup_candidates("oats", grams=200)
assert candidates[0][0] == "oats"
assert candidates[0][1].kcal == 760.0
def test_banked_candidate_no_grams(self) -> None:
"""Without grams the banked candidate is unscaled."""
remember_food("oats", Nutrition(380, 13, 67, 7, 100, "manual"))
assert lookup_candidates("oats")[0][1].kcal == 380.0
def test_staple_candidate(self) -> None:
"""A staple yields a candidate labelled by its source."""
candidates = lookup_candidates("banana", grams=100)
assert "staple: banana" in candidates[0][0]
def test_staple_candidate_no_grams(self) -> None:
"""A staple candidate with no grams stays at its 100 g basis."""
assert lookup_candidates("banana")[0][1].grams == 100.0
def test_off_candidates(self) -> None:
"""An unknown food returns the OFF alternatives, labelled by source."""
with patch(
"python_pkg.diet_guard._resolve.off_candidates",
return_value=[_OFF],
):
candidates = lookup_candidates("exotic dish")
assert candidates[0][0] == _OFF.source
class TestSuggestFoods:
"""Merged bank + staple autocomplete."""
def test_bank_ranked_first(self) -> None:
"""A banked food appears ahead of staples for the same query."""
remember_food("apple pie", Nutrition(300, 3, 50, 12, 120, "manual"))
names = [name for name, _ in suggest_foods("apple")]
assert names[0] == "apple pie"
assert "apple" in names
def test_staple_not_duplicated(self) -> None:
"""A staple already banked under the same name is not duplicated."""
remember_food("apple", Nutrition(95, 0, 25, 0, 182, "manual"))
names = [name for name, _ in suggest_foods("apple")]
assert names.count("apple") == 1

View File

@ -0,0 +1,111 @@
"""Tests for _slots.py — pure meal-slot arithmetic.
Every function is a total function of ``now`` and the slot constants, so the
time-of-day edges are exercised directly with fixed ``datetime`` values.
"""
from __future__ import annotations
from datetime import datetime, timezone
from python_pkg.diet_guard._slots import (
current_slot,
day_slots,
elapsed_slots,
missing_slots,
slot_label,
within_enforcement_window,
)
def _at(hour: int) -> datetime:
"""Return a fixed local datetime at ``hour`` (date is irrelevant here)."""
return datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc)
class TestDaySlots:
"""The fixed slot schedule derived from the constants."""
def test_default_schedule(self) -> None:
"""Slots open every 4h from 08:00 up to (not past) the 22:00 cutoff."""
assert day_slots() == (8, 12, 16, 20)
class TestEnforcementWindow:
"""The [day_start, eating_end) active window."""
def test_before_window(self) -> None:
"""An hour before the first slot is outside the window."""
assert not within_enforcement_window(_at(7))
def test_first_slot_is_inside(self) -> None:
"""The day-start hour is inside (inclusive lower bound)."""
assert within_enforcement_window(_at(8))
def test_last_active_hour_inside(self) -> None:
"""21:00 is still inside; the cutoff is exclusive at 22:00."""
assert within_enforcement_window(_at(21))
def test_cutoff_is_outside(self) -> None:
"""The cutoff hour itself is outside (exclusive upper bound)."""
assert not within_enforcement_window(_at(22))
class TestElapsedSlots:
"""Which slots have arrived as of now."""
def test_empty_before_window(self) -> None:
"""Before the first slot, nothing has elapsed."""
assert elapsed_slots(_at(7)) == ()
def test_empty_after_cutoff(self) -> None:
"""After the overnight cutoff, slots lapse to empty."""
assert elapsed_slots(_at(23)) == ()
def test_first_slot_only(self) -> None:
"""At 08:00 exactly, only the 08:00 slot has elapsed."""
assert elapsed_slots(_at(8)) == (8,)
def test_midday(self) -> None:
"""At 13:00, the 08:00 and 12:00 slots have elapsed."""
assert elapsed_slots(_at(13)) == (8, 12)
def test_all_elapsed_late(self) -> None:
"""At 21:00, every slot for the day has elapsed."""
assert elapsed_slots(_at(21)) == (8, 12, 16, 20)
class TestMissingSlots:
"""Elapsed slots not yet satisfied by a logged meal."""
def test_none_missing_when_all_logged(self) -> None:
"""All elapsed slots logged -> nothing due."""
assert missing_slots(_at(13), {8, 12}) == ()
def test_reports_unlogged(self) -> None:
"""Only the unlogged elapsed slots are returned, ascending."""
assert missing_slots(_at(17), {8}) == (12, 16)
class TestCurrentSlot:
"""The most recent elapsed slot (used to tag a CLI ``ate``)."""
def test_none_before_any_slot(self) -> None:
"""Before the first slot there is no current slot."""
assert current_slot(_at(7)) is None
def test_latest_elapsed(self) -> None:
"""At 13:00 the current slot is 12:00 (the latest elapsed)."""
assert current_slot(_at(13)) == 12
class TestSlotLabel:
"""Human HH:00 labels."""
def test_morning_zero_padded(self) -> None:
"""A single-digit hour is zero-padded."""
assert slot_label(8) == "08:00"
def test_evening(self) -> None:
"""A two-digit hour formats plainly."""
assert slot_label(20) == "20:00"

View File

@ -0,0 +1,248 @@
"""Tests for _state.py — the HMAC-signed daily food log.
State files are redirected into ``tmp_path`` and a deterministic HMAC key is
provided by the autouse conftest fixtures, so signing, verification, and the
defensive read paths are all exercised in isolation.
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from python_pkg.diet_guard import _state
from python_pkg.diet_guard._budget import BudgetNotInitializedError, seal_budget
from python_pkg.diet_guard._estimator import Nutrition
from python_pkg.diet_guard._state import (
consumption_band,
entry_kcal,
load_log,
log_meal,
logged_slots_today,
now_local,
remaining_budget,
today_entries,
today_total_kcal,
today_total_macros,
undo_last_today,
)
def _nut(
kcal: float, *, protein: float = 0, carbs: float = 0, fat: float = 0
) -> Nutrition:
"""Build a Nutrition for a logged meal."""
return Nutrition(kcal, protein, carbs, fat, 100, "manual")
def _raw() -> dict[str, list[dict[str, object]]]:
"""Read the raw log file as parsed JSON (no verification)."""
return json.loads(_state.FOOD_LOG_FILE.read_text(encoding="utf-8"))
class TestClock:
"""Time helpers."""
def test_now_local_is_aware(self) -> None:
"""now_local returns a timezone-aware datetime."""
assert now_local().tzinfo is not None
class TestEntryFloat:
"""Numeric field coercion."""
def test_missing_is_zero(self) -> None:
"""An absent field reads as 0.0."""
assert entry_kcal({}) == 0.0
def test_bool_is_zero(self) -> None:
"""A bool calorie value is rejected as 0.0."""
assert _state._entry_float({"kcal": True}, "kcal") == 0.0
def test_number_passes(self) -> None:
"""A real number is returned as a float."""
assert entry_kcal({"kcal": 321}) == 321.0
def test_non_numeric_is_zero(self) -> None:
"""A non-numeric field reads as 0.0."""
assert _state._entry_float({"kcal": "lots"}, "kcal") == 0.0
class TestLogAndTotals:
"""Logging meals and aggregating the day."""
def test_log_and_total(self) -> None:
"""A logged meal counts toward the day's calories."""
log_meal("toast", _nut(150), slot=8)
assert today_total_kcal() == 150.0
def test_entry_carries_signature(self) -> None:
"""With a key present, the stored entry is signed."""
entry = log_meal("toast", _nut(150), slot=8)
assert "hmac" in entry
def test_unsigned_when_no_key(self) -> None:
"""With no key, the entry is written unsigned and still read back."""
with patch.object(_state, "compute_entry_hmac", return_value=None):
log_meal("toast", _nut(150), slot=8)
assert "hmac" not in _raw()[next(iter(_raw()))][0]
assert today_total_kcal() == 150.0
def test_macros_sum(self) -> None:
"""today_total_macros sums protein/carbs/fat across entries."""
log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=8)
log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=12)
assert today_total_macros() == (16.0, 45.0, 11.0)
def test_slotless_entry_counts_calories_only(self) -> None:
"""An entry logged with no slot adds calories but satisfies no slot."""
log_meal("snack", _nut(99))
assert today_total_kcal() == 99.0
assert logged_slots_today() == set()
class TestLoggedSlots:
"""Which slots today's log has satisfied."""
def test_int_slots_counted(self) -> None:
"""Integer slot tags are reported."""
log_meal("a", _nut(1), slot=8)
log_meal("b", _nut(1), slot=12)
assert logged_slots_today() == {8, 12}
def test_bool_slot_excluded(self) -> None:
"""A bool masquerading as a slot is ignored."""
log_meal("a", _nut(1), slot=8)
raw = _raw()
day = next(iter(raw))
raw[day].append({"kcal": 1, "slot": True})
_state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8")
assert logged_slots_today() == {8}
class TestReadDefensive:
"""The raw read tolerates missing/corrupt/mis-shaped files."""
def test_missing_file(self) -> None:
"""No file -> empty log."""
assert _state._read_raw_log() == {}
def test_corrupt_json(self) -> None:
"""Unparsable content -> empty log."""
_state.FOOD_LOG_FILE.write_text("nope", encoding="utf-8")
assert _state._read_raw_log() == {}
def test_top_level_not_dict(self) -> None:
"""A non-object top level -> empty log."""
_state.FOOD_LOG_FILE.write_text("[1,2]", encoding="utf-8")
assert _state._read_raw_log() == {}
def test_filters_non_list_and_non_dict(self) -> None:
"""Non-list day values are dropped; non-dict entries are filtered out."""
_state.FOOD_LOG_FILE.write_text(
json.dumps({"2026-06-08": [{"kcal": 1}, 99], "junk": "notalist"}),
encoding="utf-8",
)
result = _state._read_raw_log()
assert result == {"2026-06-08": [{"kcal": 1}]}
class TestVerification:
"""Tamper detection on read via the shared HMAC key."""
def test_valid_entry_kept(self) -> None:
"""A correctly signed entry survives verification."""
log_meal("toast", _nut(150), slot=8)
assert today_entries()
def test_tampered_entry_dropped(self) -> None:
"""An edited calorie value invalidates the signature and is dropped."""
log_meal("toast", _nut(150), slot=8)
raw = _raw()
day = next(iter(raw))
raw[day][0]["kcal"] = 999
_state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8")
assert today_entries() == []
def test_unsigned_rejected_when_key_present(self) -> None:
"""An entry with no signature is rejected while a key exists."""
_state.FOOD_LOG_FILE.write_text(
json.dumps({_state._today(): [{"kcal": 1}]}),
encoding="utf-8",
)
assert today_entries() == []
def test_unsigned_accepted_when_no_key(self) -> None:
"""With no key at all, an unsigned entry is tolerated."""
_state.FOOD_LOG_FILE.write_text(
json.dumps({_state._today(): [{"kcal": 5}]}),
encoding="utf-8",
)
with patch.object(_state, "compute_entry_hmac", return_value=None):
assert len(today_entries()) == 1
def test_load_log_drops_emptied_days(self) -> None:
"""A day whose every entry is invalid is omitted entirely."""
_state.FOOD_LOG_FILE.write_text(
json.dumps({_state._today(): [{"kcal": 1}]}),
encoding="utf-8",
)
assert load_log() == {}
class TestBudgetViews:
"""Remaining budget and the qualitative band."""
def test_remaining_requires_budget(self) -> None:
"""With no budget sealed, remaining_budget raises."""
with pytest.raises(BudgetNotInitializedError):
remaining_budget()
def test_remaining_value(self) -> None:
"""Remaining is budget minus today's total."""
seal_budget(2000)
log_meal("lunch", _nut(500), slot=12)
assert remaining_budget() == 1500.0
def test_band_on_track(self) -> None:
"""Well under the warn fraction is 'on track'."""
seal_budget(2000)
log_meal("a", _nut(500), slot=8)
assert consumption_band() == "on track"
def test_band_approaching(self) -> None:
"""At or above the warn fraction but under budget is 'approaching limit'."""
seal_budget(2000)
log_meal("a", _nut(1700), slot=8)
assert consumption_band() == "approaching limit"
def test_band_over(self) -> None:
"""At or above budget is 'OVER BUDGET'."""
seal_budget(2000)
log_meal("a", _nut(2100), slot=8)
assert consumption_band() == "OVER BUDGET"
class TestUndo:
"""Removing the most recent entry."""
def test_nothing_to_undo(self) -> None:
"""An empty day undoes to None."""
assert undo_last_today() is None
def test_undo_leaves_earlier_entries(self) -> None:
"""Undo removes only the last entry when others remain."""
log_meal("a", _nut(100), slot=8)
log_meal("b", _nut(200), slot=12)
removed = undo_last_today()
assert removed is not None
assert removed["desc"] == "b"
assert today_total_kcal() == 100.0
def test_undo_last_entry_clears_day(self) -> None:
"""Undoing the only entry removes the day from the log."""
log_meal("a", _nut(100), slot=8)
undo_last_today()
assert _state._read_raw_log() == {}