From 400f89b469394a1359490137b651ef4299573bda Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 10 Jun 2026 22:31:18 +0200 Subject: [PATCH] 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 --- diet_guard/__init__.py | 12 + diet_guard/__main__.py | 10 + diet_guard/_budget.py | 317 +++++++ diet_guard/_cli.py | 498 +++++++++++ diet_guard/_constants.py | 65 ++ diet_guard/_estimator.py | 325 +++++++ diet_guard/_foodbank.py | 281 ++++++ diet_guard/_fuzzy.py | 63 ++ diet_guard/_gate.py | 67 ++ diet_guard/_gatelock.py | 1334 ++++++++++++++++++++++++++++ diet_guard/_meal.py | 65 ++ diet_guard/_portions.py | 171 ++++ diet_guard/_resolve.py | 161 ++++ diet_guard/_slots.py | 111 +++ diet_guard/_state.py | 271 ++++++ diet_guard/diet-guard-gate.service | 25 + diet_guard/diet-guard-gate.timer | 20 + diet_guard/docs/design.md | 54 ++ diet_guard/install.sh | 77 ++ diet_guard/tests/__init__.py | 0 diet_guard/tests/conftest.py | 69 ++ diet_guard/tests/test_budget.py | 272 ++++++ diet_guard/tests/test_cli.py | 279 ++++++ diet_guard/tests/test_estimator.py | 220 +++++ diet_guard/tests/test_foodbank.py | 206 +++++ diet_guard/tests/test_fuzzy.py | 46 + diet_guard/tests/test_gate.py | 79 ++ diet_guard/tests/test_gatelock.py | 917 +++++++++++++++++++ diet_guard/tests/test_main.py | 21 + diet_guard/tests/test_meal.py | 60 ++ diet_guard/tests/test_portions.py | 65 ++ diet_guard/tests/test_resolve.py | 141 +++ diet_guard/tests/test_slots.py | 111 +++ diet_guard/tests/test_state.py | 248 ++++++ 34 files changed, 6661 insertions(+) create mode 100644 diet_guard/__init__.py create mode 100644 diet_guard/__main__.py create mode 100644 diet_guard/_budget.py create mode 100644 diet_guard/_cli.py create mode 100644 diet_guard/_constants.py create mode 100644 diet_guard/_estimator.py create mode 100644 diet_guard/_foodbank.py create mode 100644 diet_guard/_fuzzy.py create mode 100644 diet_guard/_gate.py create mode 100644 diet_guard/_gatelock.py create mode 100644 diet_guard/_meal.py create mode 100644 diet_guard/_portions.py create mode 100644 diet_guard/_resolve.py create mode 100644 diet_guard/_slots.py create mode 100644 diet_guard/_state.py create mode 100644 diet_guard/diet-guard-gate.service create mode 100644 diet_guard/diet-guard-gate.timer create mode 100644 diet_guard/docs/design.md create mode 100755 diet_guard/install.sh create mode 100644 diet_guard/tests/__init__.py create mode 100644 diet_guard/tests/conftest.py create mode 100644 diet_guard/tests/test_budget.py create mode 100644 diet_guard/tests/test_cli.py create mode 100644 diet_guard/tests/test_estimator.py create mode 100644 diet_guard/tests/test_foodbank.py create mode 100644 diet_guard/tests/test_fuzzy.py create mode 100644 diet_guard/tests/test_gate.py create mode 100644 diet_guard/tests/test_gatelock.py create mode 100644 diet_guard/tests/test_main.py create mode 100644 diet_guard/tests/test_meal.py create mode 100644 diet_guard/tests/test_portions.py create mode 100644 diet_guard/tests/test_resolve.py create mode 100644 diet_guard/tests/test_slots.py create mode 100644 diet_guard/tests/test_state.py diff --git a/diet_guard/__init__.py b/diet_guard/__init__.py new file mode 100644 index 0000000..699446d --- /dev/null +++ b/diet_guard/__init__.py @@ -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. +""" diff --git a/diet_guard/__main__.py b/diet_guard/__main__.py new file mode 100644 index 0000000..04086d6 --- /dev/null +++ b/diet_guard/__main__.py @@ -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()) diff --git a/diet_guard/_budget.py b/diet_guard/_budget.py new file mode 100644 index 0000000..a031700 --- /dev/null +++ b/diet_guard/_budget.py @@ -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) diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py new file mode 100644 index 0000000..0ecf846 --- /dev/null +++ b/diet_guard/_cli.py @@ -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 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() diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py new file mode 100644 index 0000000..7bbc463 --- /dev/null +++ b/diet_guard/_constants.py @@ -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" diff --git a/diet_guard/_estimator.py b/diet_guard/_estimator.py new file mode 100644 index 0000000..2cbfbd7 --- /dev/null +++ b/diet_guard/_estimator.py @@ -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) diff --git a/diet_guard/_foodbank.py b/diet_guard/_foodbank.py new file mode 100644 index 0000000..8ea3d22 --- /dev/null +++ b/diet_guard/_foodbank.py @@ -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] + ] diff --git a/diet_guard/_fuzzy.py b/diet_guard/_fuzzy.py new file mode 100644 index 0000000..92a6f93 --- /dev/null +++ b/diet_guard/_fuzzy.py @@ -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) diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py new file mode 100644 index 0000000..e323112 --- /dev/null +++ b/diet_guard/_gate.py @@ -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." diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py new file mode 100644 index 0000000..d40ba0c --- /dev/null +++ b/diet_guard/_gatelock.py @@ -0,0 +1,1334 @@ +"""Fullscreen "log your meals to unlock" gate window for diet_guard. + +This reuses the proven screen-locker *mechanism* -- an ``overrideredirect`` +fullscreen window with a global input grab and disabled VT switching -- but +hardens two latent gaps in that original so a grabbed window can never become a +trap: + +* **VT switching is restored on every exit path**, not just the clean one: + ``atexit`` covers a crash/uncaught exception, signal handlers cover + SIGTERM/SIGINT, and a ``try/finally`` covers normal return. +* **Every callback error is swallowed and surfaced**, via a + ``report_callback_exception`` override on the Tk root, so no exception can + propagate out of the grabbed event loop and leave a dead window. + +The window walks the user through each *missing* meal slot in turn (coming home +at 17:00 backfills 08:00, then 12:00, then 16:00) and dismisses only once every +elapsed slot carries a logged meal. + +Resolution is built around one idea: the macro fields plus the "per" field hold +the food's nutrition *as a reference for some amount*, and how much you ate +scales that reference into the total that is logged. Measure by **grams** and +the reference is "per 100 g" off a label; measure by **items** and it is "per 1 +item" (with the piece's approximate weight, which you can correct). Either way +the total shown in the preview is exactly what gets recorded, and changing how +much you ate never rewrites the reference fields, so the two cannot desync. As +you type, the picker offers your banked foods and built-in staples, so a common +food fills in one click. Leaving the calorie field blank looks the food up +(food bank, then staples, then Open Food Facts), fills the fields, names the +source, and offers alternatives. A running dashboard makes the day's calories +prominent, with macros and the protein target beneath. The unlock condition is +*logging*, never *estimating correctly*: a manual calorie value always works +offline, so a dead OFF endpoint can never trap you behind the lock. +""" + +from __future__ import annotations + +import atexit +import contextlib +import fcntl +import logging +import shutil +import signal +import subprocess +import sys +import time +import tkinter as tk +from typing import TYPE_CHECKING + +from python_pkg.diet_guard._budget import ( + BudgetError, + daily_budget, + protein_target_g, +) +from python_pkg.diet_guard._constants import GATE_LOCK_FILE +from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition +from python_pkg.diet_guard._foodbank import remember_food, remember_meal +from python_pkg.diet_guard._gate import due_slots +from python_pkg.diet_guard._meal import MealItem, meal_total +from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams +from python_pkg.diet_guard._resolve import lookup_candidates, suggest_foods +from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label +from python_pkg.diet_guard._state import ( + entry_kcal, + log_meal, + now_local, + today_entries, + today_total_kcal, + today_total_macros, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from types import FrameType, TracebackType + from typing import TextIO + +_logger = logging.getLogger(__name__) + +# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). +_BG = "#1a1a1a" +_FG = "#e0e0e0" +_ACCENT = "#00ff88" +_ERR = "#ff6666" +_FIELD_BG = "#2a2a2a" +_MUTED = "#9a9a9a" +# How long the "unlocking..." confirmation lingers before the window tears down. +_UNLOCK_DELAY_MS = 1200 +# Periodic no-op so the grabbed, event-starved loop keeps handing control back +# to Python, letting SIGTERM/SIGINT be serviced promptly. +_KEEPALIVE_MS = 250 +# A global input grab fails while another X client already holds one -- most +# often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A +# single attempt then falls back to a *local* grab, which on an override-redirect +# window the WM refuses to focus means no keystroke ever reaches the field -- the +# "can't type anything" lock-trap. So the grab is retried for the window's whole +# life: the gate waits out the game and captures input the instant it is freed. +_GRAB_RETRY_MS = 200 +# How often (in attempts) to log that the grab is still blocked, so the journal +# shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms. +_GRAB_LOG_EVERY = 25 +# Number of food-bank / staple / OFF suggestions shown in the picker list. +_SUGGESTION_ROWS = 5 +# Grams a label's macros are assumed to describe when the "per" field is blank. +_DEFAULT_PER_GRAMS = 100.0 +# Unit-selector choices for how a portion is measured. +_UNIT_GRAMS = "grams" +_UNIT_ITEMS = "items" +# Per-basis label prefixes for the two measuring modes. +_BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" +_BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" +# How many recent meals the dashboard lists. +_DASHBOARD_ROWS = 5 +# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16. +_TIME_SLICE = slice(11, 16) +# Width a meal description is truncated to in the dashboard. +_DASH_DESC_WIDTH = 22 +# Fallback name for a multi-item meal when the user leaves the name field blank. +_DEFAULT_MEAL_NAME = "meal" +# -- display readiness (session-start race) --------------------------------- +# The gate's systemd timer fires the instant the user systemd instance starts +# (Persistent=true catch-up of the slot missed while the PC was off), which on a +# fresh login can BEAT the display manager writing ~/.Xauthority and the X server +# becoming reachable. That race -- not the slot logic -- is what silently +# dropped the session-start launch: _GateRoot() raised TclError ("couldn't +# connect to display") and the oneshot service died. So before building the +# window we poll the display until it is connectable; on timeout the gate exits +# cleanly and the next timer tick retries, instead of crashing. +_DISPLAY_WAIT_TIMEOUT_S = 60.0 +_DISPLAY_POLL_INTERVAL_S = 1.0 + + +def _display_is_ready() -> bool: + """Return True if a Tk root can connect to the X display right now. + + Builds and immediately destroys a throwaway, unmapped root -- the cheapest + way to ask "is DISPLAY reachable and authorized?" without opening a visible + window. A missing display or a not-yet-written X auth cookie raises + ``tk.TclError``, which is reported here as not-ready. + """ + try: + probe = tk.Tk() + except tk.TclError: + return False + probe.destroy() + return True + + +def wait_for_display( + *, + timeout_s: float = _DISPLAY_WAIT_TIMEOUT_S, + interval_s: float = _DISPLAY_POLL_INTERVAL_S, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, +) -> bool: + """Block until the X display is connectable, or ``timeout_s`` elapses. + + Absorbs the session-start race in which the gate's timer fires before the + display manager has finished writing the X auth cookie (see the module + note). ``sleep`` and ``monotonic`` are injectable so the wait is tested + without real time passing. + + Args: + timeout_s: Total seconds to keep retrying before giving up. + interval_s: Seconds to wait between connection probes. + sleep: Sleep function (injected in tests). + monotonic: Monotonic clock (injected in tests). + + Returns: + True as soon as a probe connects; False if the deadline passes with the + display still unreachable (the caller should defer to the next tick). + """ + deadline = monotonic() + timeout_s + while True: + if _display_is_ready(): + return True + if monotonic() >= deadline: + _logger.warning( + "X display unreachable after %.0fs (session still settling?); " + "deferring the gate to the next timer tick", + timeout_s, + ) + return False + sleep(interval_s) + + +def _assert_not_under_pytest() -> None: + """Raise if a real Tk gate is being built inside a pytest run. + + Defence-in-depth: prevents a real fullscreen window from locking the screen + when a test forgets to mock ``tk.Tk``. When ``tk`` is mocked the module + name is no longer ``tkinter``, so genuine mocked tests pass straight through. + """ + if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter": + msg = "SAFETY: MealGate built under pytest with real tkinter (tk.Tk unmocked)" + raise RuntimeError(msg) + + +def _safe_float(raw: str) -> float | None: + """Return ``raw`` parsed as a float, or None if it is blank/non-numeric.""" + if not raw: + return None + try: + return float(raw) + except ValueError: + return None + + +def _format_preview(nutrition: Nutrition) -> str: + """Render the one-line "this is what will be logged" preview.""" + portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" + return ( + f"→ {nutrition.kcal:g} kcal · " + f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" + f"{portion} · {nutrition.source}" + ) + + +def acquire_gate_lock() -> TextIO | None: + """Acquire the gate's single-instance ``flock``. + + Returns: + An open file handle that must be kept alive for the gate's lifetime + (closing it releases the lock), or None if another gate already holds + it -- in which case the caller must not open a second window. + """ + GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) + handle = GATE_LOCK_FILE.open("w", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return None + return handle + + +def release_gate_lock(handle: TextIO) -> None: + """Release the single-instance lock and close its handle.""" + with contextlib.suppress(OSError): + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + handle.close() + + +def _pending_slots(*, demo_mode: bool) -> list[int]: + """Return the slots the window must collect before it can unlock. + + In production this is exactly the elapsed-but-unlogged slots. In demo mode + -- where there may be nothing genuinely due -- fall back to a representative + slot so the UI is always demonstrable. + + Args: + demo_mode: Whether the window is a safe sandbox. + + Returns: + The slot hours to collect, ascending. + """ + pending = list(due_slots()) + if pending: + return pending + if demo_mode: + return [current_slot(now_local()) or day_slots()[0]] + return [] + + +class _GateRoot(tk.Tk): + """Tk root that routes callback errors to a handler instead of crashing. + + Overriding ``report_callback_exception`` is the idiomatic, blind-except-free + way to guarantee that no exception raised inside a Tk callback escapes the + event loop -- essential while a global input grab is held. + """ + + on_callback_error: Callable[[], None] | None = None + + def report_callback_exception( + self, + exc: type[BaseException], + val: BaseException, + tb: TracebackType | None, + ) -> None: + """Log a callback error and notify the handler; never re-raise.""" + _logger.error("gate callback error", exc_info=(exc, val, tb)) + if self.on_callback_error is not None: + self.on_callback_error() + + +class MealGate: + """A fullscreen lock that dismisses only once every missing slot is logged.""" + + def __init__(self, *, demo_mode: bool = True) -> None: + """Build the lock window. + + Args: + demo_mode: When True, use a local (not global) input grab and add a + close button, so the gate can be exercised without locking the + real session. Production passes False. + """ + _assert_not_under_pytest() + self.demo_mode = demo_mode + self._vt_disabled = False + self._pending = _pending_slots(demo_mode=demo_mode) + # Provenance of the values currently in the reference fields ("manual", + # "food bank", "staple: apple", ...). Label only -- it never affects the + # maths, which read the fields directly -- so there is no second copy of + # the numbers to desync. Set when a food is picked/looked up; reset to + # "manual" the moment the user hand-edits a macro. + self._source = "manual" + # Suggestions currently listed, paired with their nutrition; the mode + # says whether picking one should also overwrite the description (bank + # entries are the user's own names) or only fill macros (OFF products). + self._suggestions: list[tuple[str, Nutrition]] = [] + self._suggestion_mode = "bank" + # The natural-basis nutrition of the food last picked or looked up (per + # 100 g for staples, per logged portion for banked foods). Kept so a + # grams<->items toggle can re-express it losslessly in the new basis; + # set to None the moment the user hand-edits a macro (then there is no + # clean reference to convert and the fields are cleared instead). + self._last_reference: Nutrition | None = None + # Components accumulated for a multi-item meal (salad + chicken + rice) + # before it is logged as one summed entry; empty for a single food. + self._meal_items: list[MealItem] = [] + self.root = _GateRoot() + self.root.on_callback_error = self._handle_callback_error + self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) + self._status = tk.StringVar(master=self.root, value="") + self._slot_header = tk.StringVar(master=self.root, value="") + self._preview = tk.StringVar(master=self.root, value="") + self._projection = tk.StringVar(master=self.root, value="") + self._cal_headline = tk.StringVar(master=self.root, value="") + self._dashboard = tk.StringVar(master=self.root, value="") + self._meal_summary = tk.StringVar(master=self.root, value="") + self._unit = tk.StringVar(master=self.root, value=_UNIT_GRAMS) + self._desc_text: tk.Text + self._amount_entry: tk.Entry + self._per_entry: tk.Entry + self._basis_prefix: tk.Label + self._kcal_entry: tk.Entry + self._protein_entry: tk.Entry + self._carbs_entry: tk.Entry + self._fat_entry: tk.Entry + self._suggestion_box: tk.Listbox + self._meal_name_entry: tk.Entry + self._status_label: tk.Label + self._build() + + # -- window mechanics (reused screen-locker pattern) -------------------- + + def _setup_window(self) -> None: + """Configure the lock window. + + Demo mode stays WM-managed so the window manager still grants it + keyboard focus -- and you can always close it -- making a usable, safe + sandbox. Only the real lock uses ``overrideredirect``, where the tiling + WM refuses focus and input is instead forced in by a global grab. + """ + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() + self.root.geometry(f"{screen_w}x{screen_h}+0+0") + self.root.attributes(topmost=True) + self.root.configure(bg=_BG, cursor="arrow") + if self.demo_mode: + self.root.attributes(fullscreen=True) + else: + self.root.overrideredirect(boolean=True) + self.root.attributes(fullscreen=True) + self._disable_vt_switching() + + def _disable_vt_switching(self) -> None: + """Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort).""" + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + _logger.warning("setxkbmap not found; VT switching stays enabled") + return + subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + self._vt_disabled = True + + def _restore_vt_switching(self) -> None: + """Re-enable VT switching; idempotent and safe to call on any exit.""" + if not self._vt_disabled: + return + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is not None: + subprocess.run([setxkbmap, "-option", ""], check=False) + self._vt_disabled = False + + def _grab_input(self) -> None: + """Force input to the window, then focus the first field. + + Demo mode relies on normal WM focus (no grab), keeping the window an + escapable sandbox. The real lock forces *all* input here with a global + grab -- the only mechanism that reaches an overrideredirect window the + tiling WM will not focus. The grab is acquired with retries because it + commonly fails on the first attempt while the window is still mapping. + """ + self.root.update_idletasks() + self.root.focus_force() + if not self.demo_mode: + self._acquire_global_grab(attempt=1) + self.root.after(100, self._focus_first_field) + + def _acquire_global_grab(self, *, attempt: int) -> None: + """Acquire the global input grab, retrying until it succeeds. + + A successful global grab is the only way keystrokes reach the + override-redirect window the WM will not focus. When another client + (typically a fullscreen game) holds the grab, the attempt is rescheduled + indefinitely rather than conceding to an unusable local grab, so the gate + waits the other application out and captures input the moment it frees + the grab. On success, focus is forced onto the description field so the + first keystroke lands there. + + Args: + attempt: 1-based attempt counter, used only to throttle the log. + """ + try: + self.root.grab_set_global() + except tk.TclError: + if attempt % _GRAB_LOG_EVERY == 0: + _logger.warning( + "global grab still blocked after %d attempts (another app -- " + "e.g. a fullscreen game -- holds it); waiting for it to free", + attempt, + ) + self.root.after( + _GRAB_RETRY_MS, + lambda: self._acquire_global_grab(attempt=attempt + 1), + ) + return + with contextlib.suppress(tk.TclError): + self.root.focus_force() + self._focus_first_field() + + def _focus_first_field(self) -> None: + """Put keyboard focus on the description entry once it is mapped.""" + with contextlib.suppress(tk.TclError): + self._desc_text.focus_force() + + # -- UI construction ---------------------------------------------------- + + def _build(self) -> None: + """Lay out the lock UI, seed the first slot prompt, and grab input.""" + self._setup_window() + frame = tk.Frame(self.root, bg=_BG) + frame.place(relx=0.5, rely=0.5, anchor="center") + + tk.Label( + frame, + text="🍽 Diet Gate", + font=("Arial", 30, "bold"), + bg=_BG, + fg=_ACCENT, + ).pack(pady=(0, 4)) + tk.Label( + frame, + textvariable=self._slot_header, + font=("Arial", 16, "bold"), + bg=_BG, + fg=_FG, + wraplength=900, + justify="center", + ).pack(pady=(0, 10)) + + self._build_desc(frame) + self._suggestion_box = self._build_suggestion_box(frame) + self._build_amount_row(frame) + self._build_macro_section(frame) + + for entry in (self._amount_entry, self._per_entry): + entry.bind("", self._on_return) + for entry in self._macro_entries(): + entry.bind("", self._on_return) + entry.bind("", self._on_macro_edit) + + tk.Label( + frame, + textvariable=self._projection, + font=("Arial", 13, "bold"), + bg=_BG, + fg=_FG, + wraplength=900, + justify="center", + ).pack(pady=(2, 2)) + + tk.Label( + frame, + textvariable=self._preview, + font=("Arial", 14, "bold"), + bg=_BG, + fg=_ACCENT, + wraplength=900, + justify="center", + ).pack(pady=(2, 6)) + + self._build_meal_controls(frame) + + tk.Button( + frame, + text="Log & Continue", + font=("Arial", 15, "bold"), + bg=_ACCENT, + fg="#003322", + activebackground="#00cc66", + cursor="hand2", + command=self._on_submit, + ).pack(pady=(4, 6)) + + self._status_label = tk.Label( + frame, + textvariable=self._status, + font=("Arial", 12), + bg=_BG, + fg=_FG, + wraplength=900, + justify="center", + ) + self._status_label.pack() + + self._build_dashboard(frame) + + if self.demo_mode: + tk.Button( + self.root, + text="✕ Close Demo", + font=("Arial", 12), + bg="#ff4444", + fg="white", + command=self.close, + cursor="hand2", + ).place(x=10, y=10) + + self._relabel_basis() + self._refresh_slot_header() + self._refresh_dashboard() + self._refresh_projection() + self._grab_input() + self._desc_text.focus_set() + + def _build_desc(self, parent: tk.Frame) -> None: + """Build the wrapping, multi-line "what did you eat?" description box. + + A multi-line ``Text`` (not an ``Entry``) so a long restaurant + description wraps onto a second line and stays fully visible, instead of + scrolling off the right edge where the end can no longer be read. + """ + tk.Label( + parent, + text="What did you eat?", + font=("Arial", 12), + bg=_BG, + fg=_FG, + ).pack() + text = tk.Text( + parent, + font=("Arial", 15), + width=64, + height=2, + wrap="word", + bg=_FIELD_BG, + fg=_FG, + insertbackground=_FG, + highlightthickness=1, + highlightbackground=_MUTED, + ) + text.pack(pady=(2, 6)) + text.bind("", self._on_desc_keyrelease) + text.bind("", self._on_desc_return) + self._desc_text = text + + def _get_desc(self) -> str: + """Return the description text, trimmed (a Text always trails a newline).""" + return self._desc_text.get("1.0", "end-1c").strip() + + def _set_desc(self, value: str) -> None: + """Replace the description box's contents with ``value``.""" + self._desc_text.delete("1.0", tk.END) + if value: + self._desc_text.insert("1.0", value) + + def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str: + """Submit on Enter in the description box, suppressing the newline.""" + self._on_submit() + return "break" + + def _numeric_entry(self, parent: tk.Frame, *, width: int) -> tk.Entry: + """Return an entry that only accepts a number or a blank string.""" + vcmd = (self.root.register(self._is_numeric_or_blank), "%P") + return tk.Entry( + parent, + font=("Arial", 15), + width=width, + bg=_FIELD_BG, + fg=_FG, + insertbackground=_FG, + justify="center", + validate="key", + validatecommand=vcmd, + ) + + @staticmethod + def _is_numeric_or_blank(proposed: str) -> bool: + """Validate-on-key predicate: allow only a blank field or a number.""" + if proposed == "": + return True + try: + float(proposed) + except ValueError: + return False + return True + + def _build_suggestion_box(self, parent: tk.Frame) -> tk.Listbox: + """Build the food-bank / staple / OFF picker list and return it.""" + box = tk.Listbox( + parent, + font=("Arial", 12), + width=52, + height=_SUGGESTION_ROWS, + bg=_FIELD_BG, + fg=_FG, + selectbackground=_ACCENT, + selectforeground="#003322", + activestyle="none", + highlightthickness=0, + ) + box.bind("<>", self._on_suggestion_select) + box.pack(pady=(0, 8)) + return box + + def _build_amount_row(self, parent: tk.Frame) -> None: + """Build the centered "how much did you eat?" amount + unit row.""" + tk.Label( + parent, + text="How much did you eat?", + font=("Arial", 12), + bg=_BG, + fg=_FG, + ).pack() + row = tk.Frame(parent, bg=_BG) + row.pack(pady=(2, 6)) + self._amount_entry = self._numeric_entry(row, width=10) + self._amount_entry.pack(side="left", ipady=3) + self._amount_entry.bind("", self._on_amount_change) + unit_menu = tk.OptionMenu( + row, + self._unit, + _UNIT_GRAMS, + _UNIT_ITEMS, + command=self._on_unit_change, + ) + unit_menu.configure( + font=("Arial", 12), + bg=_FIELD_BG, + fg=_FG, + activebackground=_ACCENT, + highlightthickness=0, + ) + unit_menu.pack(side="left", padx=(8, 0)) + + def _build_macro_section(self, parent: tk.Frame) -> None: + """Build the per-basis field (grams or item weight) and macro row.""" + basis = tk.Frame(parent, bg=_BG) + basis.pack() + self._basis_prefix = tk.Label( + basis, + text=_BASIS_PREFIX_GRAMS, + font=("Arial", 12), + bg=_BG, + fg=_FG, + ) + self._basis_prefix.pack(side="left") + self._per_entry = self._numeric_entry(basis, width=5) + self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") + self._per_entry.pack(side="left", padx=4, ipady=2) + self._per_entry.bind("", self._on_amount_change) + tk.Label( + basis, + text="g (leave calories blank to look it up):", + font=("Arial", 12), + bg=_BG, + fg=_FG, + ).pack(side="left") + + row = tk.Frame(parent, bg=_BG) + row.pack(pady=(2, 6)) + self._kcal_entry = self._macro_cell(row, "kcal") + self._protein_entry = self._macro_cell(row, "P") + self._carbs_entry = self._macro_cell(row, "C") + self._fat_entry = self._macro_cell(row, "F") + + def _macro_cell(self, row: tk.Frame, label: str) -> tk.Entry: + """Pack one small labelled numeric entry into the macro row.""" + cell = tk.Frame(row, bg=_BG) + cell.pack(side="left", padx=6) + tk.Label(cell, text=label, font=("Arial", 11), bg=_BG, fg=_FG).pack() + entry = self._numeric_entry(cell, width=7) + entry.pack(ipady=3) + return entry + + def _macro_entries(self) -> tuple[tk.Entry, ...]: + """Return the four numeric entry widgets in (kcal, P, C, F) order.""" + return ( + self._kcal_entry, + self._protein_entry, + self._carbs_entry, + self._fat_entry, + ) + + def _build_dashboard(self, parent: tk.Frame) -> None: + """Build the running "how am I doing today" panel. + + The calorie line is large and prominent (the number the user steers by); + the meal list and macros sit beneath it in a smaller monospace block. + """ + tk.Label( + parent, + textvariable=self._cal_headline, + font=("Arial", 22, "bold"), + bg=_BG, + fg=_ACCENT, + ).pack(pady=(12, 0)) + tk.Label( + parent, + textvariable=self._dashboard, + font=("Courier", 11), + bg=_BG, + fg=_MUTED, + justify="left", + anchor="w", + wraplength=900, + ).pack(pady=(2, 0)) + + def _build_meal_controls(self, parent: tk.Frame) -> None: + """Build the optional multi-item meal row: name, add button, running sum. + + Logging stays one-tap for a single food; these controls only matter when + a meal has several separately-macroed parts (a dinner of salad + chicken + + rice). "Add item" banks the part onto the meal-in-progress and clears + the form for the next one; "Log & Continue" then logs the summed meal. + """ + row = tk.Frame(parent, bg=_BG) + row.pack(pady=(2, 2)) + tk.Label( + row, + text="Meal name (optional):", + font=("Arial", 11), + bg=_BG, + fg=_FG, + ).pack(side="left") + self._meal_name_entry = tk.Entry( + row, + font=("Arial", 13), + width=18, + bg=_FIELD_BG, + fg=_FG, + insertbackground=_FG, + ) + self._meal_name_entry.pack(side="left", padx=(6, 8), ipady=2) + tk.Button( + row, + text="+ Add item", + font=("Arial", 12, "bold"), + bg=_FIELD_BG, + fg=_ACCENT, + activebackground="#333333", + cursor="hand2", + command=self._on_add_item, + ).pack(side="left") + tk.Label( + parent, + textvariable=self._meal_summary, + font=("Arial", 11), + bg=_BG, + fg=_MUTED, + wraplength=900, + justify="center", + ).pack(pady=(0, 2)) + + # -- slot walk ---------------------------------------------------------- + + def _refresh_slot_header(self) -> None: + """Update the header to prompt for the slot now being collected.""" + total = len(self._pending) + if total == 0: + self._slot_header.set("All meals logged.") + return + slot = self._pending[0] + position = "" if total == 1 else f" (1 of {total} remaining)" + self._slot_header.set(f"Log your {slot_label(slot)} meal{position}") + + def _clear_food_inputs(self) -> None: + """Empty the food fields, picker, preview, and basis (keeps any meal).""" + self._set_desc("") + self._amount_entry.delete(0, tk.END) + self._unit.set(_UNIT_GRAMS) + self._relabel_basis() + self._reset_per_default() + for entry in self._macro_entries(): + entry.delete(0, tk.END) + self._suggestion_box.delete(0, tk.END) + self._suggestions = [] + self._source = "manual" + self._last_reference = None + self._preview.set("") + self._refresh_projection() + + def _clear_inputs(self) -> None: + """Empty the food fields and discard any in-progress meal (new slot).""" + self._clear_food_inputs() + self._meal_items = [] + self._meal_name_entry.delete(0, tk.END) + self._meal_summary.set("") + + def _reset_per_default(self) -> None: + """Set the "per" field to the basis default for the current unit.""" + self._per_entry.delete(0, tk.END) + if self._unit.get() == _UNIT_ITEMS: + grams = estimate_unit_grams(self._get_desc()) + self._per_entry.insert( + 0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}" + ) + else: + self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") + + def _relabel_basis(self) -> None: + """Point the per-basis label at grams or per-item for the current unit.""" + items = self._unit.get() == _UNIT_ITEMS + self._basis_prefix.config( + text=_BASIS_PREFIX_ITEMS if items else _BASIS_PREFIX_GRAMS, + ) + + # -- field helpers ------------------------------------------------------ + + def _basis_grams(self) -> float: + """Return the grams the label macros describe (per 100 g or per item). + + Honours an explicit "per" value when the user has typed one; otherwise + falls back to one piece's weight in items mode, or 100 g in grams mode. + """ + typed = _safe_float(self._per_entry.get().strip()) + if typed is not None and typed > 0: + return typed + if self._unit.get() == _UNIT_ITEMS: + grams = estimate_unit_grams(self._get_desc()) + return grams if grams is not None else DEFAULT_ITEM_GRAMS + return _DEFAULT_PER_GRAMS + + def _eaten_grams(self) -> float | None: + """Return how many grams were eaten, or None if no amount is entered. + + In grams mode the amount *is* the grams; in items mode it is multiplied + by one piece's weight (the "per" field), so "5 apples" becomes a weight. + """ + amount = _safe_float(self._amount_entry.get().strip()) + if amount is None: + return None + if self._unit.get() == _UNIT_ITEMS: + return amount * self._basis_grams() + return amount + + def _macro_values(self) -> tuple[float | None, ...] | None: + """Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric.""" + values: list[float | None] = [] + for entry in self._macro_entries(): + raw = entry.get().strip() + parsed = _safe_float(raw) + if raw and parsed is None: + return None + values.append(parsed) + return tuple(values) + + def _set_entry(self, entry: tk.Entry, value: str) -> None: + """Replace an entry's contents with ``value``.""" + entry.delete(0, tk.END) + entry.insert(0, value) + + def _fill_macro_fields(self, nutrition: Nutrition) -> None: + """Write a nutrition's macros into the kcal/P/C/F fields.""" + pairs = zip( + self._macro_entries(), + ( + nutrition.kcal, + nutrition.protein_g, + nutrition.carbs_g, + nutrition.fat_g, + ), + strict=True, + ) + for entry, value in pairs: + self._set_entry(entry, f"{value:g}") + + # -- the reference -> total model -------------------------------------- + + def _reference_nutrition(self) -> Nutrition | None: + """Return the label values as a Nutrition, or None if calories are blank. + + This is the *reference* (macros for one basis -- per 100 g, or per item), + not the total: how much was eaten scales it in :meth:`_current_nutrition`. + """ + values = self._macro_values() + if values is None or values[0] is None: + return None + return Nutrition( + kcal=values[0], + protein_g=values[1] or 0.0, + carbs_g=values[2] or 0.0, + fat_g=values[3] or 0.0, + grams=self._basis_grams(), + source=self._source, + ) + + def _current_nutrition(self) -> Nutrition | None: + """Return exactly what would be logged now, or None if not yet resolvable. + + The label reference scaled to the amount eaten. With no amount yet, the + reference itself stands in (one basis portion), so the preview is never + empty just because an amount has not been typed. + """ + reference = self._reference_nutrition() + if reference is None: + return None + eaten = self._eaten_grams() + return scale_nutrition(reference, eaten) if eaten is not None else reference + + def _refresh_preview(self) -> None: + """Recompute the preview line and the live calorie projection.""" + nutrition = self._current_nutrition() + self._preview.set(_format_preview(nutrition) if nutrition is not None else "") + self._refresh_projection() + + def _refresh_projection(self) -> None: + """Show consumed / budget / remaining, and what is left after this item. + + This answers, as the calories are typed, the four numbers the user asked + to see together: how much is already eaten today, the day's goal, how + much is left now, and how much would be left *after* logging the food + currently in the form. With no budget sealed it degrades to the running + total plus this item's calories, so it is always informative. + """ + consumed = today_total_kcal() + nutrition = self._current_nutrition() + this_kcal = nutrition.kcal if nutrition is not None else 0.0 + try: + budget = daily_budget() + except (BudgetError, OSError): + tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" + self._projection.set(f"Consumed {consumed:g} kcal today{tail}") + return + left = round(budget - consumed, 1) + base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" + if this_kcal: + after = round(budget - consumed - this_kcal, 1) + self._projection.set(f"{base} → after this item: {after:g} left") + else: + self._projection.set(base) + + # -- autocomplete / lookup --------------------------------------------- + + def _on_desc_keyrelease(self, _event: tk.Event[tk.Misc]) -> None: + """Refresh suggestions; in items mode, show the piece's weight.""" + query = self._get_desc() + self._populate_suggestions(query) + # In items mode, surface a recognised piece's weight as it is typed, so + # "apple" visibly becomes "≈ 182 g" rather than a hidden assumption. + if self._unit.get() == _UNIT_ITEMS: + grams = estimate_unit_grams(query) + if grams is not None: + self._set_entry(self._per_entry, f"{grams:g}") + self._refresh_preview() + + def _populate_suggestions(self, query: str) -> None: + """Fill the picker with banked foods and matching staples for ``query``.""" + self._suggestion_mode = "bank" + self._suggestions = suggest_foods(query, limit=_SUGGESTION_ROWS) + self._suggestion_box.delete(0, tk.END) + for name, nutrition in self._suggestions: + self._suggestion_box.insert(tk.END, f"{name} ({nutrition.kcal:g} kcal)") + + def _show_candidates(self, candidates: list[tuple[str, Nutrition]]) -> None: + """Fill the picker with looked-up alternatives to choose from.""" + self._suggestion_mode = "candidates" + self._suggestions = candidates + self._suggestion_box.delete(0, tk.END) + for label, nutrition in candidates: + self._suggestion_box.insert( + tk.END, + f"{label} ({nutrition.kcal:g} kcal · {nutrition.grams:g}g)", + ) + + def _on_suggestion_select(self, _event: tk.Event[tk.Misc]) -> None: + """Fill the form from the picked suggestion.""" + selection = self._suggestion_box.curselection() + if not selection: + return + index = selection[0] + if index >= len(self._suggestions): + return + name, nutrition = self._suggestions[index] + # Banked/staple entries carry a name, so adopt it; OFF products only + # supply macros and must not overwrite what the user typed. + if self._suggestion_mode == "bank": + self._apply_reference(nutrition, name=name) + else: + self._apply_reference(nutrition) + + def _apply_reference( + self, nutrition: Nutrition, *, name: str | None = None + ) -> None: + """Adopt ``nutrition`` as the reference and mirror it into the fields. + + In grams mode the food's own weight is the "per" basis and its macros + fill the fields directly. In items mode the per-100 g reference is + converted to a single piece (its weight shown in "per"), so the macro + fields read *per item*. The amount eaten does the scaling either way. + """ + self._source = nutrition.source + self._last_reference = nutrition + if name is not None: + self._set_desc(name) + if self._unit.get() == _UNIT_ITEMS: + grams = estimate_unit_grams(self._get_desc()) + unit = grams if grams is not None else DEFAULT_ITEM_GRAMS + self._set_entry(self._per_entry, f"{unit:g}") + self._fill_macro_fields(scale_nutrition(nutrition, unit)) + else: + basis = nutrition.grams or _DEFAULT_PER_GRAMS + self._set_entry(self._per_entry, f"{basis:g}") + self._fill_macro_fields(nutrition) + # Default the eaten amount to one reference portion so a pick is + # immediately loggable (grams mode only -- items need a count). + if not self._amount_entry.get().strip() and nutrition.grams: + self._set_entry(self._amount_entry, f"{nutrition.grams:g}") + self._refresh_preview() + + # -- live recompute ----------------------------------------------------- + + def _on_amount_change(self, _event: tk.Event[tk.Misc]) -> None: + """Recompute the preview when the amount or basis changes. + + Crucially this does *not* rewrite the macro fields: those hold the label + reference, and only the previewed/logged total reflects the new amount. + """ + self._refresh_preview() + + def _on_unit_change(self, _value: str) -> None: + """Switch grams<->items, re-expressing the picked food in the new basis. + + The macro fields mean different things in each mode (per 100 g / per + portion vs per item). When a food was picked or looked up, its stored + reference is re-applied so toggling converts the values back and forth + losslessly. A hand-typed (manual) entry has no clean reference to + convert, so its fields are cleared to be re-entered in the new basis + rather than silently reinterpreted. + """ + self._relabel_basis() + self._amount_entry.delete(0, tk.END) + if self._last_reference is not None: + self._apply_reference(self._last_reference) + return + for entry in self._macro_entries(): + entry.delete(0, tk.END) + self._reset_per_default() + self._source = "manual" + self._refresh_preview() + + def _on_macro_edit(self, _event: tk.Event[tk.Misc]) -> None: + """A hand-edited macro becomes the manual reference from here on. + + Editing a macro by hand invalidates the picked food's stored reference: + the fields no longer match it, so a later unit toggle must not snap them + back to it. + """ + self._source = "manual" + self._last_reference = None + self._refresh_preview() + + # -- behaviour ---------------------------------------------------------- + + def _set_status(self, text: str, *, error: bool = False) -> None: + """Update the status line, red for errors.""" + self._status.set(text) + self._status_label.config(fg=_ERR if error else _FG) + + def _on_return(self, _event: tk.Event[tk.Misc]) -> None: + """Handle the Enter key in any entry field.""" + self._on_submit() + + def _on_submit(self) -> None: + """Validate, then look up, or log -- as a single food or a summed meal. + + With a meal in progress, an empty form finalizes the accumulated items, + and a completed form adds itself as the meal's last item before logging. + With no meal in progress this is the original single-food path. + """ + description = self._get_desc() + if not description: + if self._meal_items: + self._log_meal() + return + self._set_status("Type what you ate first.", error=True) + self._desc_text.focus_set() + return + + values = self._macro_values() + if values is None: + self._set_status("Macros must be numbers.", error=True) + self._kcal_entry.focus_set() + return + + if values[0] is None: + self._begin_lookup(description) + return + nutrition = self._current_nutrition() + if nutrition is None: + self._set_status("Enter the calories, then submit.", error=True) + self._kcal_entry.focus_set() + return + if self._meal_items: + self._meal_items.append(MealItem(description, nutrition)) + self._log_meal() + return + self._record(description, nutrition) + + def _begin_lookup(self, description: str) -> None: + """Step 1: look the food up, fill the label fields, offer alternatives. + + Nothing is logged here -- the user must see and confirm the filled + values (a second submit) before they are recorded. The food is looked + up at its natural basis (per 100 g / serving); the amount eaten scales + it, so the lookup never bakes in a portion. + """ + self._set_status("looking up…") + self.root.update_idletasks() + candidates = lookup_candidates(description) + if not candidates: + self._set_status( + "Couldn't look that up. Enter the calories yourself, then submit.", + error=True, + ) + self._kcal_entry.focus_set() + return + self._show_candidates(candidates) + self._apply_reference(candidates[0][1]) + source = candidates[0][1].source + tail = ( + "Review, or pick another below, then submit to log." + if len(candidates) > 1 + else "Review the values, then submit to log." + ) + self._set_status(f"Filled from {source}. {tail}") + + def _record(self, description: str, nutrition: Nutrition) -> None: + """Log and bank a single food for the current slot, then advance.""" + log_meal(description, nutrition, self._slot_for_log()) + remember_food(description, nutrition) + self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") + + def _meal_name(self) -> str: + """Return the trimmed meal name the user typed (empty if none).""" + return self._meal_name_entry.get().strip() + + def _refresh_meal_summary(self) -> None: + """Update the running "meal so far" line from the accumulated items.""" + if not self._meal_items: + self._meal_summary.set("") + return + total = meal_total(self._meal_items) + names = ", ".join(item.name for item in self._meal_items) + self._meal_summary.set( + f"Meal so far ({len(self._meal_items)}): {names} → " + f"{total.kcal:g} kcal · P{total.protein_g:g} " + f"C{total.carbs_g:g} F{total.fat_g:g}", + ) + + def _on_add_item(self) -> None: + """Add the current form as one component of a multi-part meal. + + Requires a name and resolved calories (a blank calorie field triggers a + lookup first, exactly like submitting). On success the item is appended + to the meal-in-progress, the running total updates, and the food fields + clear for the next item while the meal name is kept. + """ + description = self._get_desc() + if not description: + self._set_status("Type the item first, then add it.", error=True) + self._desc_text.focus_set() + return + values = self._macro_values() + if values is None: + self._set_status("Macros must be numbers.", error=True) + self._kcal_entry.focus_set() + return + if values[0] is None: + self._begin_lookup(description) + return + nutrition = self._current_nutrition() + if nutrition is None: + self._set_status("Enter the calories, then add the item.", error=True) + self._kcal_entry.focus_set() + return + self._meal_items.append(MealItem(description, nutrition)) + self._refresh_meal_summary() + self._clear_food_inputs() + self._set_status(f"Added {description}. Add another, or Log & Continue.") + self._desc_text.focus_set() + + def _slot_for_log(self) -> int | None: + """Return the slot to tag a log with -- None in demo (satisfies no slot). + + A synthetic demo slot must never satisfy a real checkpoint, so demo logs + are slot-less: they still bank the food and update the dashboard, but do + not silently stop the production gate from firing. + """ + return None if self.demo_mode else self._pending[0] + + def _log_meal(self) -> None: + """Log the accumulated multi-item meal for the current slot and advance. + + Each component and the summed composite are banked (see + :func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is + satisfied by the summed total under the meal's name. + """ + name = self._meal_name() or _DEFAULT_MEAL_NAME + count = len(self._meal_items) + total = remember_meal(name, list(self._meal_items)) + log_meal(name, total, self._slot_for_log()) + self._meal_items = [] + self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)") + + def _finish_slot(self, summary: str) -> None: + """Advance past the current slot after something was logged for it. + + Args: + summary: A short description of what was logged (calories/source, or + the meal name and item count), shown in the confirmation line. + """ + slot = self._pending[0] + self._pending.pop(0) + self._refresh_dashboard() + logged = f"Logged {slot_label(slot)}: {summary}" + if not self._pending: + self._unlock(logged) + return + self._clear_inputs() + self._refresh_slot_header() + self._set_status(f"{logged} — next meal, please.") + self._desc_text.focus_set() + + def _unlock(self, logged: str) -> None: + """Confirm the final log and tear the window down. + + Teardown is scheduled *before* the budget is looked up, so a broken + budget seal (which raises) can never re-trap the user at unlock time. + """ + self._set_status(f"{logged} — all meals logged, unlocking…") + self.root.after(_UNLOCK_DELAY_MS, self.close) + + # -- dashboard ---------------------------------------------------------- + + def _refresh_dashboard(self) -> None: + """Recompute the prominent calorie headline and the detail panel.""" + self._cal_headline.set(self._cal_headline_text()) + self._dashboard.set(self._dashboard_text()) + + def _cal_headline_text(self) -> str: + """Return the big calories-today line: consumed, target, and remaining.""" + consumed = today_total_kcal() + try: + budget = daily_budget() + except (BudgetError, OSError): + return f"{consumed:g} kcal today" + return ( + f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" + ) + + def _dashboard_text(self) -> str: + """Build the detail panel: recent meals, then macros and protein.""" + lines = ["── Today ───────────────────────────────"] + entries = today_entries() + if entries: + for entry in entries[-_DASHBOARD_ROWS:]: + clock = str(entry.get("time", ""))[_TIME_SLICE] + desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH] + lines.append( + f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} " + f"{entry_kcal(entry):>5.0f} kcal", + ) + else: + lines.append(" (nothing logged yet today)") + protein, carbs, fat = today_total_macros() + lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") + target = protein_target_g() + if target is not None: + left = round(target - protein, 1) + lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") + return "\n".join(lines) + + def _handle_callback_error(self) -> None: + """Surface an unexpected callback error without dropping the grab.""" + self._set_status( + "Something went wrong. Enter the calories, then submit again.", + error=True, + ) + with contextlib.suppress(tk.TclError): + self._kcal_entry.focus_set() + + # -- lifecycle ---------------------------------------------------------- + + def _install_signal_handlers(self) -> None: + """Ensure VT switching is restored on crash or kill, not just close.""" + atexit.register(self._restore_vt_switching) + for sig in (signal.SIGTERM, signal.SIGINT): + with contextlib.suppress(ValueError): + signal.signal(sig, self._on_signal) + + def _on_signal(self, _signum: int, _frame: FrameType | None) -> None: + """Restore the keyboard escape, then exit, on SIGTERM/SIGINT.""" + self._restore_vt_switching() + raise SystemExit(0) + + def _keepalive(self) -> None: + """Re-arm a periodic no-op so pending signals get serviced promptly.""" + self.root.after(_KEEPALIVE_MS, self._keepalive) + + def close(self) -> None: + """Restore VT switching and destroy the window (no process exit).""" + self._restore_vt_switching() + with contextlib.suppress(tk.TclError): + self.root.destroy() + + def run(self) -> None: + """Run the Tk loop, restoring VT switching on every exit path.""" + self._install_signal_handlers() + self._keepalive() + try: + self.root.mainloop() + finally: + self._restore_vt_switching() diff --git a/diet_guard/_meal.py b/diet_guard/_meal.py new file mode 100644 index 0000000..39d92f7 --- /dev/null +++ b/diet_guard/_meal.py @@ -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, + ) diff --git a/diet_guard/_portions.py b/diet_guard/_portions.py new file mode 100644 index 0000000..61c1683 --- /dev/null +++ b/diet_guard/_portions.py @@ -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: "``), 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]] diff --git a/diet_guard/_resolve.py b/diet_guard/_resolve.py new file mode 100644 index 0000000..72d61af --- /dev/null +++ b/diet_guard/_resolve.py @@ -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] diff --git a/diet_guard/_slots.py b/diet_guard/_slots.py new file mode 100644 index 0000000..285d412 --- /dev/null +++ b/diet_guard/_slots.py @@ -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" diff --git a/diet_guard/_state.py b/diet_guard/_state.py new file mode 100644 index 0000000..f29bd72 --- /dev/null +++ b/diet_guard/_state.py @@ -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 diff --git a/diet_guard/diet-guard-gate.service b/diet_guard/diet-guard-gate.service new file mode 100644 index 0000000..9132404 --- /dev/null +++ b/diet_guard/diet-guard-gate.service @@ -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 diff --git a/diet_guard/diet-guard-gate.timer b/diet_guard/diet-guard-gate.timer new file mode 100644 index 0000000..f73a29d --- /dev/null +++ b/diet_guard/diet-guard-gate.timer @@ -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 diff --git a/diet_guard/docs/design.md b/diet_guard/docs/design.md new file mode 100644 index 0000000..1909fa6 --- /dev/null +++ b/diet_guard/docs/design.md @@ -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 diff --git a/diet_guard/install.sh b/diet_guard/install.sh new file mode 100755 index 0000000..f98ca42 --- /dev/null +++ b/diet_guard/install.sh @@ -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" diff --git a/diet_guard/tests/__init__.py b/diet_guard/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py new file mode 100644 index 0000000..de83026 --- /dev/null +++ b/diet_guard/tests/conftest.py @@ -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 diff --git a/diet_guard/tests/test_budget.py b/diet_guard/tests/test_budget.py new file mode 100644 index 0000000..48f5ab9 --- /dev/null +++ b/diet_guard/tests/test_budget.py @@ -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() diff --git a/diet_guard/tests/test_cli.py b/diet_guard/tests/test_cli.py new file mode 100644 index 0000000..f2a5f16 --- /dev/null +++ b/diet_guard/tests/test_cli.py @@ -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 diff --git a/diet_guard/tests/test_estimator.py b/diet_guard/tests/test_estimator.py new file mode 100644 index 0000000..999da00 --- /dev/null +++ b/diet_guard/tests/test_estimator.py @@ -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 diff --git a/diet_guard/tests/test_foodbank.py b/diet_guard/tests/test_foodbank.py new file mode 100644 index 0000000..9687688 --- /dev/null +++ b/diet_guard/tests/test_foodbank.py @@ -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() == {} diff --git a/diet_guard/tests/test_fuzzy.py b/diet_guard/tests/test_fuzzy.py new file mode 100644 index 0000000..cc04b3a --- /dev/null +++ b/diet_guard/tests/test_fuzzy.py @@ -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") diff --git a/diet_guard/tests/test_gate.py b/diet_guard/tests/test_gate.py new file mode 100644 index 0000000..8cb6b95 --- /dev/null +++ b/diet_guard/tests/test_gate.py @@ -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." diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py new file mode 100644 index 0000000..5573ccc --- /dev/null +++ b/diet_guard/tests/test_gatelock.py @@ -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() diff --git a/diet_guard/tests/test_main.py b/diet_guard/tests/test_main.py new file mode 100644 index 0000000..984f684 --- /dev/null +++ b/diet_guard/tests/test_main.py @@ -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 diff --git a/diet_guard/tests/test_meal.py b/diet_guard/tests/test_meal.py new file mode 100644 index 0000000..b013102 --- /dev/null +++ b/diet_guard/tests/test_meal.py @@ -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 diff --git a/diet_guard/tests/test_portions.py b/diet_guard/tests/test_portions.py new file mode 100644 index 0000000..ce195db --- /dev/null +++ b/diet_guard/tests/test_portions.py @@ -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") == [] diff --git a/diet_guard/tests/test_resolve.py b/diet_guard/tests/test_resolve.py new file mode 100644 index 0000000..b54613b --- /dev/null +++ b/diet_guard/tests/test_resolve.py @@ -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 diff --git a/diet_guard/tests/test_slots.py b/diet_guard/tests/test_slots.py new file mode 100644 index 0000000..4baf5f3 --- /dev/null +++ b/diet_guard/tests/test_slots.py @@ -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" diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py new file mode 100644 index 0000000..8773aab --- /dev/null +++ b/diet_guard/tests/test_state.py @@ -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() == {}