From 091045fd67f6f39cf5d310e4261168c9004c2990 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 22 Jun 2026 12:22:41 +0200 Subject: [PATCH] Remove diet_guard: extracted to its own repo (kuhyx/diet-guard) Full history preserved via git filter-repo; production diet-guard-gate.service already cut over to the pip-installed standalone package and verified clean. No other monorepo file referenced diet_guard, so removal is a clean delete. --- CLAUDE.md | 1 + .../remove-diet-guard-2026-06-22.json | 46 ++ python_pkg/diet_guard/__init__.py | 12 - python_pkg/diet_guard/__main__.py | 10 - python_pkg/diet_guard/_budget.py | 318 ----------- python_pkg/diet_guard/_cli.py | 498 ------------------ python_pkg/diet_guard/_constants.py | 65 --- python_pkg/diet_guard/_estimator.py | 325 ------------ python_pkg/diet_guard/_foodbank.py | 273 ---------- python_pkg/diet_guard/_fuzzy.py | 63 --- python_pkg/diet_guard/_gate.py | 67 --- python_pkg/diet_guard/_gatelock.py | 212 -------- python_pkg/diet_guard/_gatelock_core.py | 196 ------- python_pkg/diet_guard/_gatelock_mealflow.py | 302 ----------- python_pkg/diet_guard/_gatelock_nutrition.py | 230 -------- python_pkg/diet_guard/_gatelock_support.py | 82 --- python_pkg/diet_guard/_gatelock_ui.py | 458 ---------------- python_pkg/diet_guard/_meal.py | 65 --- python_pkg/diet_guard/_portions.py | 171 ------ python_pkg/diet_guard/_resolve.py | 161 ------ python_pkg/diet_guard/_slots.py | 111 ---- python_pkg/diet_guard/_state.py | 268 ---------- python_pkg/diet_guard/diet-guard-gate.service | 25 - python_pkg/diet_guard/diet-guard-gate.timer | 20 - python_pkg/diet_guard/docs/design.md | 54 -- python_pkg/diet_guard/install.sh | 77 --- python_pkg/diet_guard/tests/__init__.py | 0 python_pkg/diet_guard/tests/conftest.py | 259 --------- python_pkg/diet_guard/tests/test_budget.py | 272 ---------- python_pkg/diet_guard/tests/test_cli.py | 281 ---------- python_pkg/diet_guard/tests/test_estimator.py | 220 -------- python_pkg/diet_guard/tests/test_foodbank.py | 190 ------- python_pkg/diet_guard/tests/test_fuzzy.py | 46 -- python_pkg/diet_guard/tests/test_gate.py | 79 --- python_pkg/diet_guard/tests/test_gatelock.py | 308 ----------- .../tests/test_gatelock_mealflow.py | 425 --------------- python_pkg/diet_guard/tests/test_main.py | 21 - python_pkg/diet_guard/tests/test_meal.py | 60 --- python_pkg/diet_guard/tests/test_portions.py | 65 --- python_pkg/diet_guard/tests/test_resolve.py | 141 ----- python_pkg/diet_guard/tests/test_slots.py | 111 ---- python_pkg/diet_guard/tests/test_state.py | 248 --------- 42 files changed, 47 insertions(+), 6789 deletions(-) create mode 100644 docs/superpowers/evidence/remove-diet-guard-2026-06-22.json delete mode 100644 python_pkg/diet_guard/__init__.py delete mode 100644 python_pkg/diet_guard/__main__.py delete mode 100644 python_pkg/diet_guard/_budget.py delete mode 100644 python_pkg/diet_guard/_cli.py delete mode 100644 python_pkg/diet_guard/_constants.py delete mode 100644 python_pkg/diet_guard/_estimator.py delete mode 100644 python_pkg/diet_guard/_foodbank.py delete mode 100644 python_pkg/diet_guard/_fuzzy.py delete mode 100644 python_pkg/diet_guard/_gate.py delete mode 100644 python_pkg/diet_guard/_gatelock.py delete mode 100644 python_pkg/diet_guard/_gatelock_core.py delete mode 100644 python_pkg/diet_guard/_gatelock_mealflow.py delete mode 100644 python_pkg/diet_guard/_gatelock_nutrition.py delete mode 100644 python_pkg/diet_guard/_gatelock_support.py delete mode 100644 python_pkg/diet_guard/_gatelock_ui.py delete mode 100644 python_pkg/diet_guard/_meal.py delete mode 100644 python_pkg/diet_guard/_portions.py delete mode 100644 python_pkg/diet_guard/_resolve.py delete mode 100644 python_pkg/diet_guard/_slots.py delete mode 100644 python_pkg/diet_guard/_state.py delete mode 100644 python_pkg/diet_guard/diet-guard-gate.service delete mode 100644 python_pkg/diet_guard/diet-guard-gate.timer delete mode 100644 python_pkg/diet_guard/docs/design.md delete mode 100755 python_pkg/diet_guard/install.sh delete mode 100644 python_pkg/diet_guard/tests/__init__.py delete mode 100644 python_pkg/diet_guard/tests/conftest.py delete mode 100644 python_pkg/diet_guard/tests/test_budget.py delete mode 100644 python_pkg/diet_guard/tests/test_cli.py delete mode 100644 python_pkg/diet_guard/tests/test_estimator.py delete mode 100644 python_pkg/diet_guard/tests/test_foodbank.py delete mode 100644 python_pkg/diet_guard/tests/test_fuzzy.py delete mode 100644 python_pkg/diet_guard/tests/test_gate.py delete mode 100644 python_pkg/diet_guard/tests/test_gatelock.py delete mode 100644 python_pkg/diet_guard/tests/test_gatelock_mealflow.py delete mode 100644 python_pkg/diet_guard/tests/test_main.py delete mode 100644 python_pkg/diet_guard/tests/test_meal.py delete mode 100644 python_pkg/diet_guard/tests/test_portions.py delete mode 100644 python_pkg/diet_guard/tests/test_resolve.py delete mode 100644 python_pkg/diet_guard/tests/test_slots.py delete mode 100644 python_pkg/diet_guard/tests/test_state.py diff --git a/CLAUDE.md b/CLAUDE.md index d2073b8..5d5ab95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ Extracted to their own repos: - [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer) - [`screen-locker`](https://github.com/kuhyx/screen-locker) +- [`diet-guard`](https://github.com/kuhyx/diet-guard) Archived / unmaintained projects live in the sibling repository [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). diff --git a/docs/superpowers/evidence/remove-diet-guard-2026-06-22.json b/docs/superpowers/evidence/remove-diet-guard-2026-06-22.json new file mode 100644 index 0000000..eef9bac --- /dev/null +++ b/docs/superpowers/evidence/remove-diet-guard-2026-06-22.json @@ -0,0 +1,46 @@ +{ + "intent": "Extract diet_guard out of testsAndMisc into its own standalone repo (https://github.com/kuhyx/diet-guard), and remove it from this monorepo, mirroring the screen-locker/steam-backlog-enforcer precedent.", + "scope": [ + "Removed python_pkg/diet_guard/ (source + tests, install.sh, systemd units, docs/design.md)", + "Updated root CLAUDE.md's 'Extracted to their own repos' list", + "No other monorepo file referenced diet_guard (confirmed by grep); no orphaned coupling to clean up" + ], + "changes": [ + "git filter-repo extraction into /tmp/diet-guard-extract preserved full commit history", + "Rewrote python_pkg.diet_guard imports to diet_guard, vendored shared.coerce.as_float as diet_guard/_coerce.py", + "Scaffolded standalone pyproject.toml/.pre-commit-config.yaml/CI matching testsAndMisc's real enforced bar (pylint --fail-under=10 with tests excluded and the use-implicit-booleaness/consider-using-with disables, mypy's actual disabled-error-code set, ruff ALL, bandit, 100% branch coverage) -- found and fixed 3 places where a naive copy of screen-locker's scaffold was looser than testsAndMisc's actual gate", + "Pushed to https://github.com/kuhyx/diet-guard, pip-installed into system Python's user site-packages (pip install --user --break-system-packages), cut over diet-guard-gate.service to the pip-installed package (dropped PYTHONPATH/WorkingDirectory), verified a live systemctl --user start runs clean", + "git rm -r python_pkg/diet_guard/ from the monorepo" + ], + "verification": [ + { + "command": "cd /tmp/diet-guard-extract && pre-commit run --all-files", + "result": "pass", + "evidence": "All hooks green: ruff, ruff-format, mypy, pylint (10.00/10), bandit, codespell, shellcheck, max-file-length" + }, + { + "command": "cd /tmp/diet-guard-extract && pytest diet_guard/tests/ --cov=diet_guard --cov-branch --cov-fail-under=100", + "result": "pass", + "evidence": "271 passed, TOTAL coverage 100.00% (1375 stmts, 286 branches)" + }, + { + "command": "systemctl --user start diet-guard-gate.service && systemctl --user status diet-guard-gate.service", + "result": "pass", + "evidence": "ExecStart=/usr/bin/python -m diet_guard gate exited 0, journal: 'ok - no lock needed right now.'" + }, + { + "command": "cd ~/testsAndMisc && pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100", + "result": "pass", + "evidence": "594 passed, TOTAL coverage 100.00% after diet_guard removal" + } + ], + "risks": [ + "Production diet-guard-gate.service now depends on a git+https pip install rather than a monorepo-local path; if the GitHub repo becomes unavailable, reinstalling would fail (mitigated: package is already installed in site-packages, not re-resolved per service run)", + "screen-locker's own pyproject.toml/.pre-commit-config.yaml has the same 3 scaffold gaps just found and fixed here (pylint --fail-under=8 not 10, no tests exclude, missing 2 disables) -- not fixed as part of this task, flagged separately" + ], + "rollback": [ + "git revert this commit to restore python_pkg/diet_guard/ in the monorepo", + "Revert ~/.config/systemd/user/diet-guard-gate.service to the PYTHONPATH=%h/testsAndMisc / python -m python_pkg.diet_guard gate version and systemctl --user daemon-reload", + "pip uninstall diet_guard from system Python if reverting fully" + ] +} diff --git a/python_pkg/diet_guard/__init__.py b/python_pkg/diet_guard/__init__.py deleted file mode 100644 index 699446d..0000000 --- a/python_pkg/diet_guard/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""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/python_pkg/diet_guard/__main__.py b/python_pkg/diet_guard/__main__.py deleted file mode 100644 index 04086d6..0000000 --- a/python_pkg/diet_guard/__main__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""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/python_pkg/diet_guard/_budget.py b/python_pkg/diet_guard/_budget.py deleted file mode 100644 index 00825cc..0000000 --- a/python_pkg/diet_guard/_budget.py +++ /dev/null @@ -1,318 +0,0 @@ -"""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 gatelock.log_integrity import compute_entry_hmac - -from python_pkg.diet_guard._constants import BUDGET_FILE - -_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/python_pkg/diet_guard/_cli.py b/python_pkg/diet_guard/_cli.py deleted file mode 100644 index 096d111..0000000 --- a/python_pkg/diet_guard/_cli.py +++ /dev/null @@ -1,498 +0,0 @@ -"""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, -) -from python_pkg.diet_guard._gatelock_support import 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/python_pkg/diet_guard/_constants.py b/python_pkg/diet_guard/_constants.py deleted file mode 100644 index 7bbc463..0000000 --- a/python_pkg/diet_guard/_constants.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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/python_pkg/diet_guard/_estimator.py b/python_pkg/diet_guard/_estimator.py deleted file mode 100644 index 2cbfbd7..0000000 --- a/python_pkg/diet_guard/_estimator.py +++ /dev/null @@ -1,325 +0,0 @@ -"""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/python_pkg/diet_guard/_foodbank.py b/python_pkg/diet_guard/_foodbank.py deleted file mode 100644 index 1cc85aa..0000000 --- a/python_pkg/diet_guard/_foodbank.py +++ /dev/null @@ -1,273 +0,0 @@ -"""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 -from python_pkg.shared.coerce import as_float - -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 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/python_pkg/diet_guard/_fuzzy.py b/python_pkg/diet_guard/_fuzzy.py deleted file mode 100644 index 92a6f93..0000000 --- a/python_pkg/diet_guard/_fuzzy.py +++ /dev/null @@ -1,63 +0,0 @@ -"""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/python_pkg/diet_guard/_gate.py b/python_pkg/diet_guard/_gate.py deleted file mode 100644 index e323112..0000000 --- a/python_pkg/diet_guard/_gate.py +++ /dev/null @@ -1,67 +0,0 @@ -"""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/python_pkg/diet_guard/_gatelock.py b/python_pkg/diet_guard/_gatelock.py deleted file mode 100644 index a1cc541..0000000 --- a/python_pkg/diet_guard/_gatelock.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Fullscreen "log your meals to unlock" gate window for diet_guard. - -The fullscreen/grab/VT-disable/lifecycle mechanics -- an ``overrideredirect`` -window with a global input grab and disabled VT switching, hardened so a -grabbed window can never become a trap (VT switching restored on every exit -path, every callback error swallowed and surfaced) -- now live in the shared -``gatelock`` package, also used by wake_alarm and screen-locker. ``MealGate`` -owns a :class:`~gatelock.LockWindow` and implements -:class:`~gatelock.LockWindowHooks`. - -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. - -Building ``MealGate`` spans several sibling modules to keep each under the -repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf -widget/field helpers and state (``_GateCore``, ``_GateState``); -:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and -food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the -submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``). -``MealGate`` wires these mixins together, owns the ``gatelock.LockWindow``, -and handles construction, layout, and event binding. -""" - -from __future__ import annotations - -import contextlib -import fcntl -import sys -import tkinter as tk -from typing import TYPE_CHECKING - -from gatelock import GateRoot, LockConfig, LockWindow - -from python_pkg.diet_guard._constants import GATE_LOCK_FILE -from python_pkg.diet_guard._gate import due_slots -from python_pkg.diet_guard._gatelock_core import _GateState -from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow -from python_pkg.diet_guard._gatelock_ui import ( - BG, - GateCallbacks, - build_layout, - make_vars, -) -from python_pkg.diet_guard._slots import current_slot, day_slots -from python_pkg.diet_guard._state import now_local - -if TYPE_CHECKING: - from typing import TextIO - - -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 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 MealGate(_GateMealFlow): - """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._pending = _pending_slots(demo_mode=demo_mode) - # All mutable logical state (provenance, suggestions, meal-in-progress) - # lives in one bundle; see _GateState for the per-field rationale. - self._state = _GateState() - self.root = GateRoot() - self.root.on_callback_error = self.on_callback_error - self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) - config = LockConfig(mode="soft" if demo_mode else "hard", bg=BG) - self._lock = LockWindow(self.root, config, hooks=self) - self._vars = make_vars(self.root) - self._build() - - def _build(self) -> None: - """Lay out the UI, wire events, seed the first prompt, and grab input.""" - self._lock.setup() - callbacks = GateCallbacks( - on_unit_change=self._on_unit_change, - on_submit=self._on_submit, - on_close=self.close, - on_add_item=self._on_add_item, - ) - self._widgets = build_layout( - self.root, - self._vars, - callbacks, - demo_mode=self.demo_mode, - ) - self._wire_events() - self._relabel_basis() - self._refresh_slot_header() - self._refresh_dashboard() - self._refresh_projection() - self._lock.grab_input() - self._widgets.desc_text.focus_set() - - def on_focus_ready(self) -> None: - """Put keyboard focus on the description entry once it is mapped.""" - self._widgets.desc_text.focus_force() - - def on_close(self) -> None: - """No hardware/state to release; meal-log writes already happened.""" - - def close(self) -> None: - """Restore VT switching and destroy the window (no process exit).""" - self._lock.close() - - def run(self) -> None: - """Run the Tk loop, restoring VT switching on every exit path.""" - self._lock.run() - - def _wire_events(self) -> None: - """Bind the live per-keystroke events to the freshly built widgets. - - Construction-time commands (button and option-menu) are wired inside - ``build_layout``; the key bindings that drive lookup, scaling, and - submission are connected here, where the controller methods are in scope. - """ - widgets = self._widgets - widgets.desc_text.bind("", self._on_desc_keyrelease) - widgets.desc_text.bind("", self._on_desc_return) - widgets.suggestion_box.bind( - "<>", - self._on_suggestion_select, - ) - for entry in (widgets.amount_entry, widgets.per_entry): - entry.bind("", self._on_amount_change) - entry.bind("", self._on_return) - for entry in self._macro_entries(): - entry.bind("", self._on_return) - entry.bind("", self._on_macro_edit) - - def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str: - """Submit on Enter in the description box, suppressing the newline.""" - self._on_submit() - return "break" diff --git a/python_pkg/diet_guard/_gatelock_core.py b/python_pkg/diet_guard/_gatelock_core.py deleted file mode 100644 index 7815771..0000000 --- a/python_pkg/diet_guard/_gatelock_core.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Shared base class and state for the MealGate gate. - -Split out of :mod:`._gatelock` to keep that module under the repo's 500-line -limit. ``_GateCore`` holds the leaf widget/field helpers that every other -gatelock mixin (`_gatelock_nutrition`, `_gatelock_mealflow`) derives from, -plus the small dataclass (`_GateState`) that :mod:`._gatelock` itself depends -on. The window/lock mechanics and the ``GateRoot`` Tk root subclass that used -to live here now come from the shared ``gatelock`` package. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import logging -import tkinter as tk -from typing import TYPE_CHECKING - -from python_pkg.diet_guard._gatelock_ui import ( - BASIS_PREFIX_GRAMS, - BASIS_PREFIX_ITEMS, - DEFAULT_PER_GRAMS, - UNIT_ITEMS, - GateVars, - GateWidgets, -) -from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams -from python_pkg.diet_guard._slots import slot_label - -if TYPE_CHECKING: - from collections.abc import Callable - - from gatelock import GateRoot - - from python_pkg.diet_guard._estimator import Nutrition - from python_pkg.diet_guard._meal import MealItem - -_logger = logging.getLogger(__name__) - - -def _safe_float(raw: str) -> float | None: - """Return ``raw`` parsed as a float, or None if it is blank/non-numeric.""" - if not raw: - return None - try: - return float(raw) - except ValueError: - return None - - -@dataclass -class _GateState: - """Mutable logical state of the in-progress entry (no widget references). - - ``source`` is the provenance of the values in the reference fields - ("manual", "food bank", "staple: apple", ...). It is a label only -- the - maths read the fields directly -- so there is no second copy of the numbers - to desync; it resets to "manual" the moment a macro is hand-edited. - ``suggestions`` pairs each listed pick with its nutrition, and - ``suggestion_mode`` says whether picking one overwrites the description - (bank entries are the user's own names) or only fills macros (OFF products). - ``last_reference`` is the natural-basis nutrition of the food last picked - or looked up, kept so a grams<->items toggle can re-express it losslessly; - it is cleared the moment a macro is hand-edited. ``meal_items`` accumulates - the parts of a multi-item meal before they are logged as one summed entry. - """ - - source: str = "manual" - suggestions: list[tuple[str, Nutrition]] = field(default_factory=list) - suggestion_mode: str = "bank" - last_reference: Nutrition | None = None - meal_items: list[MealItem] = field(default_factory=list) - - -class _GateCore: - """Leaf widget/field helpers shared by every MealGate mixin. - - Declares the attributes that - :class:`~python_pkg.diet_guard._gatelock.MealGate` sets up in ``__init__`` - and ``_build`` so subclasses can reference them without tripping pylint's - no-member check. - """ - - root: GateRoot - demo_mode: bool - _pending: list[int] - _state: _GateState - _vars: GateVars - _widgets: GateWidgets - close: Callable[[], None] - - # -- description field --------------------------------------------------- - - def _get_desc(self) -> str: - """Return the description text, trimmed (a Text always trails a newline).""" - return self._widgets.desc_text.get("1.0", "end-1c").strip() - - def _set_desc(self, value: str) -> None: - """Replace the description box's contents with ``value``.""" - self._widgets.desc_text.delete("1.0", tk.END) - if value: - self._widgets.desc_text.insert("1.0", value) - - def _macro_entries(self) -> tuple[tk.Entry, ...]: - """Return the four numeric entry widgets in (kcal, P, C, F) order.""" - macros = self._widgets.macros - return (macros.kcal, macros.protein, macros.carbs, macros.fat) - - # -- slot walk -------------------------------------------------------------- - - def _refresh_slot_header(self) -> None: - """Update the header to prompt for the slot now being collected.""" - total = len(self._pending) - if total == 0: - self._vars.slot_header.set("All meals logged.") - return - slot = self._pending[0] - position = "" if total == 1 else f" (1 of {total} remaining)" - self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}") - - def _reset_per_default(self) -> None: - """Set the "per" field to the basis default for the current unit.""" - self._widgets.per_entry.delete(0, tk.END) - if self._vars.unit.get() == UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - self._widgets.per_entry.insert( - 0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}" - ) - else: - self._widgets.per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}") - - def _relabel_basis(self) -> None: - """Point the per-basis label at grams or per-item for the current unit.""" - items = self._vars.unit.get() == UNIT_ITEMS - self._widgets.basis_prefix.config( - text=BASIS_PREFIX_ITEMS if items else BASIS_PREFIX_GRAMS, - ) - - # -- field helpers ------------------------------------------------------ - - def _basis_grams(self) -> float: - """Return the grams the label macros describe (per 100 g or per item). - - Honours an explicit "per" value when the user has typed one; otherwise - falls back to one piece's weight in items mode, or 100 g in grams mode. - """ - typed = _safe_float(self._widgets.per_entry.get().strip()) - if typed is not None and typed > 0: - return typed - if self._vars.unit.get() == UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - return grams if grams is not None else DEFAULT_ITEM_GRAMS - return DEFAULT_PER_GRAMS - - def _eaten_grams(self) -> float | None: - """Return how many grams were eaten, or None if no amount is entered. - - In grams mode the amount *is* the grams; in items mode it is multiplied - by one piece's weight (the "per" field), so "5 apples" becomes a weight. - """ - amount = _safe_float(self._widgets.amount_entry.get().strip()) - if amount is None: - return None - if self._vars.unit.get() == UNIT_ITEMS: - return amount * self._basis_grams() - return amount - - def _macro_values(self) -> tuple[float | None, ...] | None: - """Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric.""" - values: list[float | None] = [] - for entry in self._macro_entries(): - raw = entry.get().strip() - parsed = _safe_float(raw) - if raw and parsed is None: - return None - values.append(parsed) - return tuple(values) - - def _set_entry(self, entry: tk.Entry, value: str) -> None: - """Replace an entry's contents with ``value``.""" - entry.delete(0, tk.END) - entry.insert(0, value) - - def _fill_macro_fields(self, nutrition: Nutrition) -> None: - """Write a nutrition's macros into the kcal/P/C/F fields.""" - pairs = zip( - self._macro_entries(), - ( - nutrition.kcal, - nutrition.protein_g, - nutrition.carbs_g, - nutrition.fat_g, - ), - strict=True, - ) - for entry, value in pairs: - self._set_entry(entry, f"{value:g}") diff --git a/python_pkg/diet_guard/_gatelock_mealflow.py b/python_pkg/diet_guard/_gatelock_mealflow.py deleted file mode 100644 index d5c1346..0000000 --- a/python_pkg/diet_guard/_gatelock_mealflow.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Submit/record/meal-building flow and dashboard for the MealGate gate. - -Split out of :mod:`._gatelock` to keep that module under the repo's 500-line -limit. ``_GateMealFlow`` extends -:class:`~python_pkg.diet_guard._gatelock_nutrition._GateNutrition` with the -submit/lookup/log flow for single foods and multi-item meals, the per-slot -input reset, and the running calorie/macro dashboard. -""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from typing import TYPE_CHECKING - -from python_pkg.diet_guard._budget import BudgetError, daily_budget, protein_target_g -from python_pkg.diet_guard._foodbank import remember_food, remember_meal -from python_pkg.diet_guard._gatelock_nutrition import _GateNutrition -from python_pkg.diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS -from python_pkg.diet_guard._meal import MealItem, meal_total -from python_pkg.diet_guard._resolve import lookup_candidates -from python_pkg.diet_guard._slots import slot_label -from python_pkg.diet_guard._state import ( - entry_kcal, - log_meal, - today_entries, - today_total_kcal, - today_total_macros, -) - -if TYPE_CHECKING: - from python_pkg.diet_guard._estimator import Nutrition - -# How long the "unlocking..." confirmation lingers before the window tears down. -_UNLOCK_DELAY_MS = 1200 -# How many recent meals the dashboard lists. -_DASHBOARD_ROWS = 5 -# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16. -_TIME_SLICE = slice(11, 16) -# Width a meal description is truncated to in the dashboard. -_DASH_DESC_WIDTH = 22 -# Fallback name for a multi-item meal when the user leaves the name field blank. -_DEFAULT_MEAL_NAME = "meal" - - -class _GateMealFlow(_GateNutrition): - """Submit/lookup/log flow for single foods and multi-item meals.""" - - # -- slot walk (meal-in-progress reset) ---------------------------------- - - def _clear_food_inputs(self) -> None: - """Empty the food fields, picker, preview, and basis (keeps any meal).""" - self._set_desc("") - self._widgets.amount_entry.delete(0, tk.END) - self._vars.unit.set(UNIT_GRAMS) - self._relabel_basis() - self._reset_per_default() - for entry in self._macro_entries(): - entry.delete(0, tk.END) - self._widgets.suggestion_box.delete(0, tk.END) - self._state.suggestions = [] - self._state.source = "manual" - self._state.last_reference = None - self._vars.preview.set("") - self._refresh_projection() - - def _clear_inputs(self) -> None: - """Empty the food fields and discard any in-progress meal (new slot).""" - self._clear_food_inputs() - self._state.meal_items = [] - self._widgets.meal_name_entry.delete(0, tk.END) - self._vars.meal_summary.set("") - - # -- behaviour ------------------------------------------------------------ - - def _set_status(self, text: str, *, error: bool = False) -> None: - """Update the status line, red for errors.""" - self._vars.status.set(text) - self._widgets.status_label.config(fg=ERR if error else FG) - - def _on_return(self, _event: tk.Event[tk.Misc]) -> None: - """Handle the Enter key in any entry field.""" - self._on_submit() - - def _on_submit(self) -> None: - """Validate, then look up, or log -- as a single food or a summed meal. - - With a meal in progress, an empty form finalizes the accumulated items, - and a completed form adds itself as the meal's last item before logging. - With no meal in progress this is the original single-food path. - """ - description = self._get_desc() - if not description: - if self._state.meal_items: - self._log_meal() - return - self._set_status("Type what you ate first.", error=True) - self._widgets.desc_text.focus_set() - return - - values = self._macro_values() - if values is None: - self._set_status("Macros must be numbers.", error=True) - self._widgets.macros.kcal.focus_set() - return - - if values[0] is None: - self._begin_lookup(description) - return - nutrition = self._current_nutrition() - if nutrition is None: - self._set_status("Enter the calories, then submit.", error=True) - self._widgets.macros.kcal.focus_set() - return - if self._state.meal_items: - self._state.meal_items.append(MealItem(description, nutrition)) - self._log_meal() - return - self._record(description, nutrition) - - def _begin_lookup(self, description: str) -> None: - """Step 1: look the food up, fill the label fields, offer alternatives. - - Nothing is logged here -- the user must see and confirm the filled - values (a second submit) before they are recorded. The food is looked - up at its natural basis (per 100 g / serving); the amount eaten scales - it, so the lookup never bakes in a portion. - """ - self._set_status("looking up…") - self.root.update_idletasks() - candidates = lookup_candidates(description) - if not candidates: - self._set_status( - "Couldn't look that up. Enter the calories yourself, then submit.", - error=True, - ) - self._widgets.macros.kcal.focus_set() - return - self._show_candidates(candidates) - self._apply_reference(candidates[0][1]) - source = candidates[0][1].source - tail = ( - "Review, or pick another below, then submit to log." - if len(candidates) > 1 - else "Review the values, then submit to log." - ) - self._set_status(f"Filled from {source}. {tail}") - - def _record(self, description: str, nutrition: Nutrition) -> None: - """Log and bank a single food for the current slot, then advance.""" - log_meal(description, nutrition, self._slot_for_log()) - remember_food(description, nutrition) - self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") - - def _meal_name(self) -> str: - """Return the trimmed meal name the user typed (empty if none).""" - return self._widgets.meal_name_entry.get().strip() - - def _refresh_meal_summary(self) -> None: - """Update the running "meal so far" line from the accumulated items.""" - if not self._state.meal_items: - self._vars.meal_summary.set("") - return - total = meal_total(self._state.meal_items) - names = ", ".join(item.name for item in self._state.meal_items) - self._vars.meal_summary.set( - f"Meal so far ({len(self._state.meal_items)}): {names} → " - f"{total.kcal:g} kcal · P{total.protein_g:g} " - f"C{total.carbs_g:g} F{total.fat_g:g}", - ) - - def _on_add_item(self) -> None: - """Add the current form as one component of a multi-part meal. - - Requires a name and resolved calories (a blank calorie field triggers a - lookup first, exactly like submitting). On success the item is appended - to the meal-in-progress, the running total updates, and the food fields - clear for the next item while the meal name is kept. - """ - description = self._get_desc() - if not description: - self._set_status("Type the item first, then add it.", error=True) - self._widgets.desc_text.focus_set() - return - values = self._macro_values() - if values is None: - self._set_status("Macros must be numbers.", error=True) - self._widgets.macros.kcal.focus_set() - return - if values[0] is None: - self._begin_lookup(description) - return - nutrition = self._current_nutrition() - if nutrition is None: - self._set_status("Enter the calories, then add the item.", error=True) - self._widgets.macros.kcal.focus_set() - return - self._state.meal_items.append(MealItem(description, nutrition)) - self._refresh_meal_summary() - self._clear_food_inputs() - self._set_status(f"Added {description}. Add another, or Log & Continue.") - self._widgets.desc_text.focus_set() - - def _slot_for_log(self) -> int | None: - """Return the slot to tag a log with -- None in demo (satisfies no slot). - - A synthetic demo slot must never satisfy a real checkpoint, so demo logs - are slot-less: they still bank the food and update the dashboard, but do - not silently stop the production gate from firing. - """ - return None if self.demo_mode else self._pending[0] - - def _log_meal(self) -> None: - """Log the accumulated multi-item meal for the current slot and advance. - - Each component and the summed composite are banked (see - :func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is - satisfied by the summed total under the meal's name. - """ - name = self._meal_name() or _DEFAULT_MEAL_NAME - count = len(self._state.meal_items) - total = remember_meal(name, list(self._state.meal_items)) - log_meal(name, total, self._slot_for_log()) - self._state.meal_items = [] - self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)") - - def _finish_slot(self, summary: str) -> None: - """Advance past the current slot after something was logged for it. - - Args: - summary: A short description of what was logged (calories/source, or - the meal name and item count), shown in the confirmation line. - """ - slot = self._pending[0] - self._pending.pop(0) - self._refresh_dashboard() - logged = f"Logged {slot_label(slot)}: {summary}" - if not self._pending: - self._unlock(logged) - return - self._clear_inputs() - self._refresh_slot_header() - self._set_status(f"{logged} — next meal, please.") - self._widgets.desc_text.focus_set() - - def _unlock(self, logged: str) -> None: - """Confirm the final log and tear the window down. - - Teardown is scheduled *before* the budget is looked up, so a broken - budget seal (which raises) can never re-trap the user at unlock time. - """ - self._set_status(f"{logged} — all meals logged, unlocking…") - self.root.after(_UNLOCK_DELAY_MS, self.close) - - # -- dashboard -------------------------------------------------------------- - - def _refresh_dashboard(self) -> None: - """Recompute the prominent calorie headline and the detail panel.""" - self._vars.cal_headline.set(self._cal_headline_text()) - self._vars.dashboard.set(self._dashboard_text()) - - def _cal_headline_text(self) -> str: - """Return the big calories-today line: consumed, target, and remaining.""" - consumed = today_total_kcal() - try: - budget = daily_budget() - except (BudgetError, OSError): - return f"{consumed:g} kcal today" - return ( - f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" - ) - - def _dashboard_text(self) -> str: - """Build the detail panel: recent meals, then macros and protein.""" - lines = ["── Today ───────────────────────────────"] - entries = today_entries() - if entries: - for entry in entries[-_DASHBOARD_ROWS:]: - clock = str(entry.get("time", ""))[_TIME_SLICE] - desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH] - lines.append( - f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} " - f"{entry_kcal(entry):>5.0f} kcal", - ) - else: - lines.append(" (nothing logged yet today)") - protein, carbs, fat = today_total_macros() - lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") - target = protein_target_g() - if target is not None: - left = round(target - protein, 1) - lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") - return "\n".join(lines) - - def on_callback_error(self) -> None: - """Surface an unexpected callback error without dropping the grab.""" - self._set_status( - "Something went wrong. Enter the calories, then submit again.", - error=True, - ) - with contextlib.suppress(tk.TclError): - self._widgets.macros.kcal.focus_set() diff --git a/python_pkg/diet_guard/_gatelock_nutrition.py b/python_pkg/diet_guard/_gatelock_nutrition.py deleted file mode 100644 index c25e219..0000000 --- a/python_pkg/diet_guard/_gatelock_nutrition.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Reference-to-total nutrition model and food lookup for the MealGate gate. - -Split out of :mod:`._gatelock` to keep that module under the repo's 500-line -limit. ``_GateNutrition`` extends -:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the -"reference -> total" nutrition maths -- the label macros describe one basis -(per 100 g or per item), and how much was eaten scales that reference into -what gets logged -- plus the live preview/projection and the -autocomplete/lookup flow that fills the reference fields from banked foods, -staples, or Open Food Facts. -""" - -from __future__ import annotations - -import tkinter as tk - -from python_pkg.diet_guard._budget import BudgetError, daily_budget -from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition -from python_pkg.diet_guard._gatelock_core import _GateCore -from python_pkg.diet_guard._gatelock_ui import ( - DEFAULT_PER_GRAMS, - SUGGESTION_ROWS, - UNIT_ITEMS, -) -from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams -from python_pkg.diet_guard._resolve import suggest_foods -from python_pkg.diet_guard._state import today_total_kcal - - -def _format_preview(nutrition: Nutrition) -> str: - """Render the one-line "this is what will be logged" preview.""" - portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" - return ( - f"→ {nutrition.kcal:g} kcal · " - f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" - f"{portion} · {nutrition.source}" - ) - - -class _GateNutrition(_GateCore): - """Reference->total nutrition maths, live preview, and food lookup.""" - - # -- the reference -> total model -------------------------------------- - - def _reference_nutrition(self) -> Nutrition | None: - """Return the label values as a Nutrition, or None if calories are blank. - - This is the *reference* (macros for one basis -- per 100 g, or per item), - not the total: how much was eaten scales it in :meth:`_current_nutrition`. - """ - values = self._macro_values() - if values is None or values[0] is None: - return None - return Nutrition( - kcal=values[0], - protein_g=values[1] or 0.0, - carbs_g=values[2] or 0.0, - fat_g=values[3] or 0.0, - grams=self._basis_grams(), - source=self._state.source, - ) - - def _current_nutrition(self) -> Nutrition | None: - """Return exactly what would be logged now, or None if not yet resolvable. - - The label reference scaled to the amount eaten. With no amount yet, the - reference itself stands in (one basis portion), so the preview is never - empty just because an amount has not been typed. - """ - reference = self._reference_nutrition() - if reference is None: - return None - eaten = self._eaten_grams() - return scale_nutrition(reference, eaten) if eaten is not None else reference - - def _refresh_preview(self) -> None: - """Recompute the preview line and the live calorie projection.""" - nutrition = self._current_nutrition() - self._vars.preview.set( - _format_preview(nutrition) if nutrition is not None else "" - ) - self._refresh_projection() - - def _refresh_projection(self) -> None: - """Show consumed / budget / remaining, and what is left after this item. - - This answers, as the calories are typed, the four numbers the user asked - to see together: how much is already eaten today, the day's goal, how - much is left now, and how much would be left *after* logging the food - currently in the form. With no budget sealed it degrades to the running - total plus this item's calories, so it is always informative. - """ - consumed = today_total_kcal() - nutrition = self._current_nutrition() - this_kcal = nutrition.kcal if nutrition is not None else 0.0 - try: - budget = daily_budget() - except (BudgetError, OSError): - tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" - self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}") - return - left = round(budget - consumed, 1) - base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" - if this_kcal: - after = round(budget - consumed - this_kcal, 1) - self._vars.projection.set(f"{base} → after this item: {after:g} left") - else: - self._vars.projection.set(base) - - # -- autocomplete / lookup --------------------------------------------- - - def _on_desc_keyrelease(self, _event: tk.Event[tk.Misc]) -> None: - """Refresh suggestions; in items mode, show the piece's weight.""" - query = self._get_desc() - self._populate_suggestions(query) - # In items mode, surface a recognised piece's weight as it is typed, so - # "apple" visibly becomes "≈ 182 g" rather than a hidden assumption. - if self._vars.unit.get() == UNIT_ITEMS: - grams = estimate_unit_grams(query) - if grams is not None: - self._set_entry(self._widgets.per_entry, f"{grams:g}") - self._refresh_preview() - - def _populate_suggestions(self, query: str) -> None: - """Fill the picker with banked foods and matching staples for ``query``.""" - self._state.suggestion_mode = "bank" - self._state.suggestions = suggest_foods(query, limit=SUGGESTION_ROWS) - self._widgets.suggestion_box.delete(0, tk.END) - for name, nutrition in self._state.suggestions: - self._widgets.suggestion_box.insert( - tk.END, f"{name} ({nutrition.kcal:g} kcal)" - ) - - def _show_candidates(self, candidates: list[tuple[str, Nutrition]]) -> None: - """Fill the picker with looked-up alternatives to choose from.""" - self._state.suggestion_mode = "candidates" - self._state.suggestions = candidates - self._widgets.suggestion_box.delete(0, tk.END) - for label, nutrition in candidates: - self._widgets.suggestion_box.insert( - tk.END, - f"{label} ({nutrition.kcal:g} kcal · {nutrition.grams:g}g)", - ) - - def _on_suggestion_select(self, _event: tk.Event[tk.Misc]) -> None: - """Fill the form from the picked suggestion.""" - selection = self._widgets.suggestion_box.curselection() - if not selection: - return - index = selection[0] - if index >= len(self._state.suggestions): - return - name, nutrition = self._state.suggestions[index] - # Banked/staple entries carry a name, so adopt it; OFF products only - # supply macros and must not overwrite what the user typed. - if self._state.suggestion_mode == "bank": - self._apply_reference(nutrition, name=name) - else: - self._apply_reference(nutrition) - - def _apply_reference( - self, nutrition: Nutrition, *, name: str | None = None - ) -> None: - """Adopt ``nutrition`` as the reference and mirror it into the fields. - - In grams mode the food's own weight is the "per" basis and its macros - fill the fields directly. In items mode the per-100 g reference is - converted to a single piece (its weight shown in "per"), so the macro - fields read *per item*. The amount eaten does the scaling either way. - """ - self._state.source = nutrition.source - self._state.last_reference = nutrition - if name is not None: - self._set_desc(name) - if self._vars.unit.get() == UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - unit = grams if grams is not None else DEFAULT_ITEM_GRAMS - self._set_entry(self._widgets.per_entry, f"{unit:g}") - self._fill_macro_fields(scale_nutrition(nutrition, unit)) - else: - basis = nutrition.grams or DEFAULT_PER_GRAMS - self._set_entry(self._widgets.per_entry, f"{basis:g}") - self._fill_macro_fields(nutrition) - # Default the eaten amount to one reference portion so a pick is - # immediately loggable (grams mode only -- items need a count). - if not self._widgets.amount_entry.get().strip() and nutrition.grams: - self._set_entry(self._widgets.amount_entry, f"{nutrition.grams:g}") - self._refresh_preview() - - # -- live recompute ----------------------------------------------------- - - def _on_amount_change(self, _event: tk.Event[tk.Misc]) -> None: - """Recompute the preview when the amount or basis changes. - - Crucially this does *not* rewrite the macro fields: those hold the label - reference, and only the previewed/logged total reflects the new amount. - """ - self._refresh_preview() - - def _on_unit_change(self, _value: str) -> None: - """Switch grams<->items, re-expressing the picked food in the new basis. - - The macro fields mean different things in each mode (per 100 g / per - portion vs per item). When a food was picked or looked up, its stored - reference is re-applied so toggling converts the values back and forth - losslessly. A hand-typed (manual) entry has no clean reference to - convert, so its fields are cleared to be re-entered in the new basis - rather than silently reinterpreted. - """ - self._relabel_basis() - self._widgets.amount_entry.delete(0, tk.END) - if self._state.last_reference is not None: - self._apply_reference(self._state.last_reference) - return - for entry in self._macro_entries(): - entry.delete(0, tk.END) - self._reset_per_default() - self._state.source = "manual" - self._refresh_preview() - - def _on_macro_edit(self, _event: tk.Event[tk.Misc]) -> None: - """A hand-edited macro becomes the manual reference from here on. - - Editing a macro by hand invalidates the picked food's stored reference: - the fields no longer match it, so a later unit toggle must not snap them - back to it. - """ - self._state.source = "manual" - self._state.last_reference = None - self._refresh_preview() diff --git a/python_pkg/diet_guard/_gatelock_support.py b/python_pkg/diet_guard/_gatelock_support.py deleted file mode 100644 index f4c93d8..0000000 --- a/python_pkg/diet_guard/_gatelock_support.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Session-start display-readiness probing for the diet_guard gate. - -Standalone infrastructure split out of :mod:`._gatelock` to keep that module -focused on the gate window itself. The gate's systemd timer fires the instant -the user systemd instance starts (``Persistent=true`` catch-up of the slot -missed while the PC was off), which on a fresh login can BEAT the display -manager writing ``~/.Xauthority`` and the X server becoming reachable. That -race -- not the slot logic -- silently dropped the session-start launch: the Tk -root raised ``TclError`` ("couldn't connect to display") and the oneshot -service died. So before building the window the launcher polls here until the -display is connectable; on timeout the gate exits cleanly and the next timer -tick retries, instead of crashing. -""" - -from __future__ import annotations - -import logging -import time -import tkinter as tk -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable - -_logger = logging.getLogger(__name__) - -_DISPLAY_WAIT_TIMEOUT_S = 60.0 -_DISPLAY_POLL_INTERVAL_S = 1.0 - - -def _display_is_ready() -> bool: - """Return True if a Tk root can connect to the X display right now. - - Builds and immediately destroys a throwaway, unmapped root -- the cheapest - way to ask "is DISPLAY reachable and authorized?" without opening a visible - window. A missing display or a not-yet-written X auth cookie raises - ``tk.TclError``, which is reported here as not-ready. - """ - try: - probe = tk.Tk() - except tk.TclError: - return False - probe.destroy() - return True - - -def wait_for_display( - *, - timeout_s: float = _DISPLAY_WAIT_TIMEOUT_S, - interval_s: float = _DISPLAY_POLL_INTERVAL_S, - sleep: Callable[[float], None] = time.sleep, - monotonic: Callable[[], float] = time.monotonic, -) -> bool: - """Block until the X display is connectable, or ``timeout_s`` elapses. - - Absorbs the session-start race in which the gate's timer fires before the - display manager has finished writing the X auth cookie (see the module - note). ``sleep`` and ``monotonic`` are injectable so the wait is tested - without real time passing. - - Args: - timeout_s: Total seconds to keep retrying before giving up. - interval_s: Seconds to wait between connection probes. - sleep: Sleep function (injected in tests). - monotonic: Monotonic clock (injected in tests). - - Returns: - True as soon as a probe connects; False if the deadline passes with the - display still unreachable (the caller should defer to the next tick). - """ - deadline = monotonic() + timeout_s - while True: - if _display_is_ready(): - return True - if monotonic() >= deadline: - _logger.warning( - "X display unreachable after %.0fs (session still settling?); " - "deferring the gate to the next timer tick", - timeout_s, - ) - return False - sleep(interval_s) diff --git a/python_pkg/diet_guard/_gatelock_ui.py b/python_pkg/diet_guard/_gatelock_ui.py deleted file mode 100644 index ea3e5a8..0000000 --- a/python_pkg/diet_guard/_gatelock_ui.py +++ /dev/null @@ -1,458 +0,0 @@ -"""Widget construction for the diet_guard meal gate. - -This module owns the *view* half of the gate: the palette, the data bundles -that hold the live string variables and the interactive widgets, and the pure -functions that lay the window out. It deliberately knows nothing about slot -logic, nutrition maths, or logging -- the controller (:mod:`._gatelock`) keeps -all of that. Splitting the construction out keeps each file focused and within -a readable size; the controller imports :func:`build_layout` and wires events -to the widgets it gets back. - -The build functions take only public parameters (the root, the string-variable -bundle, and a small callbacks bundle) and return the populated widget bundle. -Event bindings that map to controller methods are left to the controller, so no -controller internals ever cross the module boundary. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import tkinter as tk -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable - -# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). -BG = "#1a1a1a" -FG = "#e0e0e0" -_ACCENT = "#00ff88" -ERR = "#ff6666" -_FIELD_BG = "#2a2a2a" -_MUTED = "#9a9a9a" -# Number of food-bank / staple / OFF suggestions shown in the picker list. -SUGGESTION_ROWS = 5 -# Grams a label's macros are assumed to describe when the "per" field is blank. -DEFAULT_PER_GRAMS = 100.0 -# Unit-selector choices for how a portion is measured. -UNIT_GRAMS = "grams" -UNIT_ITEMS = "items" -# Per-basis label prefixes for the two measuring modes. -BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" -BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" - - -@dataclass -class _MacroEntries: - """The four macro entry widgets, in (kcal, protein, carbs, fat) order.""" - - kcal: tk.Entry - protein: tk.Entry - carbs: tk.Entry - fat: tk.Entry - - -@dataclass -class GateVars: - """Tk string variables bound to the gate's live, auto-updating fields.""" - - status: tk.StringVar - slot_header: tk.StringVar - preview: tk.StringVar - projection: tk.StringVar - cal_headline: tk.StringVar - dashboard: tk.StringVar - meal_summary: tk.StringVar - unit: tk.StringVar - - -@dataclass -class GateWidgets: - """Interactive widgets the controller reads back after the UI is built.""" - - desc_text: tk.Text - amount_entry: tk.Entry - per_entry: tk.Entry - basis_prefix: tk.Label - macros: _MacroEntries - suggestion_box: tk.Listbox - meal_name_entry: tk.Entry - status_label: tk.Label - - -@dataclass -class GateCallbacks: - """Construction-time commands the widgets fire (not key/event bindings). - - These are the callbacks that must be supplied when a widget is created -- - option-menu and button commands. Per-keystroke event bindings are wired by - the controller after the layout is built, so they are not carried here. - """ - - on_unit_change: Callable[[str], None] - on_submit: Callable[[], None] - on_close: Callable[[], None] - on_add_item: Callable[[], None] - - -def make_vars(root: tk.Misc) -> GateVars: - """Create the gate's string variables, all mastered to ``root``.""" - return GateVars( - status=tk.StringVar(master=root, value=""), - slot_header=tk.StringVar(master=root, value=""), - preview=tk.StringVar(master=root, value=""), - projection=tk.StringVar(master=root, value=""), - cal_headline=tk.StringVar(master=root, value=""), - dashboard=tk.StringVar(master=root, value=""), - meal_summary=tk.StringVar(master=root, value=""), - unit=tk.StringVar(master=root, value=UNIT_GRAMS), - ) - - -def is_numeric_or_blank(proposed: str) -> bool: - """Validate-on-key predicate: allow only a blank field or a number.""" - if proposed == "": - return True - try: - float(proposed) - except ValueError: - return False - return True - - -def _numeric_entry(root: tk.Misc, parent: tk.Frame, *, width: int) -> tk.Entry: - """Return an entry that only accepts a number or a blank string.""" - vcmd = (root.register(is_numeric_or_blank), "%P") - return tk.Entry( - parent, - font=("Arial", 15), - width=width, - bg=_FIELD_BG, - fg=FG, - insertbackground=FG, - justify="center", - validate="key", - validatecommand=vcmd, - ) - - -def _macro_cell(root: tk.Misc, row: tk.Frame, label: str) -> tk.Entry: - """Pack one small labelled numeric entry into the macro row.""" - cell = tk.Frame(row, bg=BG) - cell.pack(side="left", padx=6) - tk.Label(cell, text=label, font=("Arial", 11), bg=BG, fg=FG).pack() - entry = _numeric_entry(root, cell, width=7) - entry.pack(ipady=3) - return entry - - -def _build_desc(parent: tk.Frame) -> tk.Text: - """Build and return the multi-line "what did you eat?" description box. - - A multi-line ``Text`` (not an ``Entry``) so a long restaurant description - wraps onto a second line and stays fully visible, instead of scrolling off - the right edge where the end can no longer be read. - """ - tk.Label( - parent, - text="What did you eat?", - font=("Arial", 12), - bg=BG, - fg=FG, - ).pack() - text = tk.Text( - parent, - font=("Arial", 15), - width=64, - height=2, - wrap="word", - bg=_FIELD_BG, - fg=FG, - insertbackground=FG, - highlightthickness=1, - highlightbackground=_MUTED, - ) - text.pack(pady=(2, 6)) - return text - - -def _build_suggestion_box(parent: tk.Frame) -> tk.Listbox: - """Build the food-bank / staple / OFF picker list and return it.""" - box = tk.Listbox( - parent, - font=("Arial", 12), - width=52, - height=SUGGESTION_ROWS, - bg=_FIELD_BG, - fg=FG, - selectbackground=_ACCENT, - selectforeground="#003322", - activestyle="none", - highlightthickness=0, - ) - box.pack(pady=(0, 8)) - return box - - -def _build_amount_row( - root: tk.Misc, - parent: tk.Frame, - unit_var: tk.StringVar, - on_unit_change: Callable[[str], None], -) -> tk.Entry: - """Build the "how much did you eat?" amount + unit row; return the entry.""" - tk.Label( - parent, - text="How much did you eat?", - font=("Arial", 12), - bg=BG, - fg=FG, - ).pack() - row = tk.Frame(parent, bg=BG) - row.pack(pady=(2, 6)) - amount_entry = _numeric_entry(root, row, width=10) - amount_entry.pack(side="left", ipady=3) - unit_menu = tk.OptionMenu( - row, - unit_var, - UNIT_GRAMS, - UNIT_ITEMS, - command=on_unit_change, - ) - unit_menu.configure( - font=("Arial", 12), - bg=_FIELD_BG, - fg=FG, - activebackground=_ACCENT, - highlightthickness=0, - ) - unit_menu.pack(side="left", padx=(8, 0)) - return amount_entry - - -def _build_macro_section( - root: tk.Misc, - parent: tk.Frame, -) -> tuple[tk.Label, tk.Entry, _MacroEntries]: - """Build the per-basis field and macro row. - - Returns the basis-prefix label, the "per" entry, and the four macro entries, - for the caller to store in the widget bundle. - """ - basis = tk.Frame(parent, bg=BG) - basis.pack() - basis_prefix = tk.Label( - basis, - text=BASIS_PREFIX_GRAMS, - font=("Arial", 12), - bg=BG, - fg=FG, - ) - basis_prefix.pack(side="left") - per_entry = _numeric_entry(root, basis, width=5) - per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}") - per_entry.pack(side="left", padx=4, ipady=2) - tk.Label( - basis, - text="g (leave calories blank to look it up):", - font=("Arial", 12), - bg=BG, - fg=FG, - ).pack(side="left") - - row = tk.Frame(parent, bg=BG) - row.pack(pady=(2, 6)) - macros = _MacroEntries( - kcal=_macro_cell(root, row, "kcal"), - protein=_macro_cell(root, row, "P"), - carbs=_macro_cell(root, row, "C"), - fat=_macro_cell(root, row, "F"), - ) - return basis_prefix, per_entry, macros - - -def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None: - """Build the running "how am I doing today" panel. - - The calorie line is large and prominent (the number the user steers by); the - meal list and macros sit beneath it in a smaller monospace block. - """ - tk.Label( - parent, - textvariable=vars_.cal_headline, - font=("Arial", 22, "bold"), - bg=BG, - fg=_ACCENT, - ).pack(pady=(12, 0)) - tk.Label( - parent, - textvariable=vars_.dashboard, - font=("Courier", 11), - bg=BG, - fg=_MUTED, - justify="left", - anchor="w", - wraplength=900, - ).pack(pady=(2, 0)) - - -def _build_meal_controls( - parent: tk.Frame, - vars_: GateVars, - on_add_item: Callable[[], None], -) -> tk.Entry: - """Build the optional multi-item meal row; return the meal-name entry. - - Logging stays one-tap for a single food; these controls only matter when a - meal has several separately-macroed parts (a dinner of salad + chicken + - rice). "Add item" banks the part onto the meal-in-progress and clears the - form for the next one; "Log & Continue" then logs the summed meal. - """ - row = tk.Frame(parent, bg=BG) - row.pack(pady=(2, 2)) - tk.Label( - row, - text="Meal name (optional):", - font=("Arial", 11), - bg=BG, - fg=FG, - ).pack(side="left") - meal_name_entry = tk.Entry( - row, - font=("Arial", 13), - width=18, - bg=_FIELD_BG, - fg=FG, - insertbackground=FG, - ) - meal_name_entry.pack(side="left", padx=(6, 8), ipady=2) - tk.Button( - row, - text="+ Add item", - font=("Arial", 12, "bold"), - bg=_FIELD_BG, - fg=_ACCENT, - activebackground="#333333", - cursor="hand2", - command=on_add_item, - ).pack(side="left") - tk.Label( - parent, - textvariable=vars_.meal_summary, - font=("Arial", 11), - bg=BG, - fg=_MUTED, - wraplength=900, - justify="center", - ).pack(pady=(0, 2)) - return meal_name_entry - - -def build_layout( - root: tk.Misc, - vars_: GateVars, - callbacks: GateCallbacks, - *, - demo_mode: bool, -) -> GateWidgets: - """Lay out the whole gate UI and return the widgets the controller drives. - - The controller calls this once (after configuring the window) and is then - responsible for binding per-keystroke events to the returned widgets. - """ - frame = tk.Frame(root, bg=BG) - frame.place(relx=0.5, rely=0.5, anchor="center") - - tk.Label( - frame, - text="🍽 Diet Gate", - font=("Arial", 30, "bold"), - bg=BG, - fg=_ACCENT, - ).pack(pady=(0, 4)) - tk.Label( - frame, - textvariable=vars_.slot_header, - font=("Arial", 16, "bold"), - bg=BG, - fg=FG, - wraplength=900, - justify="center", - ).pack(pady=(0, 10)) - - desc_text = _build_desc(frame) - suggestion_box = _build_suggestion_box(frame) - amount_entry = _build_amount_row( - root, - frame, - vars_.unit, - callbacks.on_unit_change, - ) - basis_prefix, per_entry, macros = _build_macro_section(root, frame) - - tk.Label( - frame, - textvariable=vars_.projection, - font=("Arial", 13, "bold"), - bg=BG, - fg=FG, - wraplength=900, - justify="center", - ).pack(pady=(2, 2)) - tk.Label( - frame, - textvariable=vars_.preview, - font=("Arial", 14, "bold"), - bg=BG, - fg=_ACCENT, - wraplength=900, - justify="center", - ).pack(pady=(2, 6)) - - meal_name_entry = _build_meal_controls(frame, vars_, callbacks.on_add_item) - - tk.Button( - frame, - text="Log & Continue", - font=("Arial", 15, "bold"), - bg=_ACCENT, - fg="#003322", - activebackground="#00cc66", - cursor="hand2", - command=callbacks.on_submit, - ).pack(pady=(4, 6)) - - status_label = tk.Label( - frame, - textvariable=vars_.status, - font=("Arial", 12), - bg=BG, - fg=FG, - wraplength=900, - justify="center", - ) - status_label.pack() - - _build_dashboard(frame, vars_) - - if demo_mode: - tk.Button( - root, - text="✕ Close Demo", - font=("Arial", 12), - bg="#ff4444", - fg="white", - command=callbacks.on_close, - cursor="hand2", - ).place(x=10, y=10) - - return GateWidgets( - desc_text=desc_text, - amount_entry=amount_entry, - per_entry=per_entry, - basis_prefix=basis_prefix, - macros=macros, - suggestion_box=suggestion_box, - meal_name_entry=meal_name_entry, - status_label=status_label, - ) diff --git a/python_pkg/diet_guard/_meal.py b/python_pkg/diet_guard/_meal.py deleted file mode 100644 index 39d92f7..0000000 --- a/python_pkg/diet_guard/_meal.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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/python_pkg/diet_guard/_portions.py b/python_pkg/diet_guard/_portions.py deleted file mode 100644 index 61c1683..0000000 --- a/python_pkg/diet_guard/_portions.py +++ /dev/null @@ -1,171 +0,0 @@ -"""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/python_pkg/diet_guard/_resolve.py b/python_pkg/diet_guard/_resolve.py deleted file mode 100644 index 72d61af..0000000 --- a/python_pkg/diet_guard/_resolve.py +++ /dev/null @@ -1,161 +0,0 @@ -"""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/python_pkg/diet_guard/_slots.py b/python_pkg/diet_guard/_slots.py deleted file mode 100644 index 285d412..0000000 --- a/python_pkg/diet_guard/_slots.py +++ /dev/null @@ -1,111 +0,0 @@ -"""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/python_pkg/diet_guard/_state.py b/python_pkg/diet_guard/_state.py deleted file mode 100644 index e4b205e..0000000 --- a/python_pkg/diet_guard/_state.py +++ /dev/null @@ -1,268 +0,0 @@ -"""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 gatelock.log_integrity import ( - compute_entry_hmac, - verify_entry_hmac, -) - -from python_pkg.diet_guard._budget import daily_budget -from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE -from python_pkg.shared.coerce import as_float - -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. - """ - return as_float(entry.get(key)) - - -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/python_pkg/diet_guard/diet-guard-gate.service b/python_pkg/diet_guard/diet-guard-gate.service deleted file mode 100644 index 9132404..0000000 --- a/python_pkg/diet_guard/diet-guard-gate.service +++ /dev/null @@ -1,25 +0,0 @@ -[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/python_pkg/diet_guard/diet-guard-gate.timer b/python_pkg/diet_guard/diet-guard-gate.timer deleted file mode 100644 index f73a29d..0000000 --- a/python_pkg/diet_guard/diet-guard-gate.timer +++ /dev/null @@ -1,20 +0,0 @@ -[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/python_pkg/diet_guard/docs/design.md b/python_pkg/diet_guard/docs/design.md deleted file mode 100644 index 1909fa6..0000000 --- a/python_pkg/diet_guard/docs/design.md +++ /dev/null @@ -1,54 +0,0 @@ -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/python_pkg/diet_guard/install.sh b/python_pkg/diet_guard/install.sh deleted file mode 100755 index f98ca42..0000000 --- a/python_pkg/diet_guard/install.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/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/python_pkg/diet_guard/tests/__init__.py b/python_pkg/diet_guard/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/python_pkg/diet_guard/tests/conftest.py b/python_pkg/diet_guard/tests/conftest.py deleted file mode 100644 index 41dcd00..0000000 --- a/python_pkg/diet_guard/tests/conftest.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Shared fixtures for diet_guard tests. - -Three 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. -* ``_block_real_vt`` makes ``gatelock``'s VT-switch disable a no-op, so a - prod-mode (``demo_mode=False``) gate built in a test never runs a real - ``setxkbmap`` against the live X session. - -The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...) -build a demo :class:`~python_pkg.diet_guard._gatelock.MealGate` whose widgets -are functional in-memory stand-ins, shared by ``test_gatelock.py`` and -``test_gatelock_mealflow.py``. -""" - -from __future__ import annotations - -from contextlib import ExitStack -from types import SimpleNamespace -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -from python_pkg.diet_guard import ( - _gatelock, - _gatelock_core, - _gatelock_mealflow, - _gatelock_nutrition, - _gatelock_ui, -) -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._gatelock import MealGate - -if TYPE_CHECKING: - from collections.abc import Iterator - from pathlib import Path - - -@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 _block_real_vt() -> Iterator[None]: - """Make gatelock's VT-switch disable a no-op for every test. - - Belt-and-suspenders alongside ``_block_real_tk``: VT-disable now lives in - ``gatelock``, independent of the (mocked) root, so a test that builds a - real prod-mode (``demo_mode=False``) gate would otherwise run a genuine - ``setxkbmap`` against whatever X session the test happens to run under. - """ - with patch("gatelock._vt.shutil.which", return_value=None): - 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("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key): - yield - - -# -------------------------------------------------------------------------- -# Gate fixture and its functional tk fakes -# -------------------------------------------------------------------------- -# -# A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a -# real, catchable ``TclError``) replaces the blanket MagicMock above for the -# duration of each gate test, so the window's *logic* runs for real against -# in-memory widgets without ever opening a window or grabbing the keyboard. - - -class _FakeTclError(Exception): - """Stand-in for ``tkinter.TclError`` (a real, catchable exception).""" - - -class FakeVar: - """A functional ``StringVar``: stores and returns a string.""" - - def __init__(self, master: object = None, value: str = "") -> None: - self._value = value - - def get(self) -> str: - return self._value - - def set(self, value: str) -> None: - self._value = value - - -class FakeEntry: - """A functional one-line entry (delete clears, insert appends).""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self._value = "" - - def get(self) -> str: - return self._value - - def delete(self, first: object, last: object = None) -> None: - self._value = "" - - def insert(self, index: object, text: str) -> None: - self._value += text - - def pack(self, *args: object, **kwargs: object) -> FakeEntry: - return self - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - def configure(self, *args: object, **kwargs: object) -> None: - pass - - config = configure - - def focus_set(self) -> None: - pass - - def focus_force(self) -> None: - pass - - -class FakeText(FakeEntry): - """A functional multi-line text box (``get`` ignores the index range).""" - - def get(self, start: object = None, end: object = None) -> str: - return self._value - - -class FakeListbox: - """A functional listbox tracking items and the current selection.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self._items: list[str] = [] - self._sel: tuple[int, ...] = () - - def delete(self, first: object, last: object = None) -> None: - self._items = [] - - def insert(self, index: object, text: str) -> None: - self._items.append(text) - - def curselection(self) -> tuple[int, ...]: - return self._sel - - def selection_set(self, index: int) -> None: - self._sel = (index,) - - def selection_clear(self, first: object, last: object = None) -> None: - self._sel = () - - def pack(self, *args: object, **kwargs: object) -> FakeListbox: - return self - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - -class FakeWidget: - """A generic no-op widget for Frame/Label/Button/OptionMenu.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - def pack(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - def place(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - def configure(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - config = configure - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - -_FAKE_TK = SimpleNamespace( - END="end", - TclError=_FakeTclError, - StringVar=FakeVar, - Frame=FakeWidget, - Label=FakeWidget, - Button=FakeWidget, - OptionMenu=FakeWidget, - Entry=FakeEntry, - Text=FakeText, - Listbox=FakeListbox, - Event=object, -) - -# Every mixin module the gate window is built from imports ``tkinter`` -# independently; all of them must see the fake so ``tk.TclError`` etc. are the -# catchable ``_FakeTclError`` everywhere a test raises it. -_GATE_TK_MODULES = ( - _gatelock, - _gatelock_core, - _gatelock_nutrition, - _gatelock_mealflow, - _gatelock_ui, -) - - -@pytest.fixture -def gate() -> Iterator[MealGate]: - """Build a demo gate whose widgets are functional fakes.""" - with ExitStack() as stack: - for module in _GATE_TK_MODULES: - stack.enter_context(patch.object(module, "tk", _FAKE_TK)) - yield MealGate(demo_mode=True) - - -def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition: - """A simple reference nutrition for driving the gate form.""" - return Nutrition(kcal, 10, 20, 5, grams, "food bank") diff --git a/python_pkg/diet_guard/tests/test_budget.py b/python_pkg/diet_guard/tests/test_budget.py deleted file mode 100644 index 48f5ab9..0000000 --- a/python_pkg/diet_guard/tests/test_budget.py +++ /dev/null @@ -1,272 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_cli.py b/python_pkg/diet_guard/tests/test_cli.py deleted file mode 100644 index c2fb9d3..0000000 --- a/python_pkg/diet_guard/tests/test_cli.py +++ /dev/null @@ -1,281 +0,0 @@ -"""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"), - patch.object(_cli, "wait_for_display", return_value=True), - ): - 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"), - patch.object(_cli, "wait_for_display", return_value=True), - ): - 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/python_pkg/diet_guard/tests/test_estimator.py b/python_pkg/diet_guard/tests/test_estimator.py deleted file mode 100644 index 999da00..0000000 --- a/python_pkg/diet_guard/tests/test_estimator.py +++ /dev/null @@ -1,220 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_foodbank.py b/python_pkg/diet_guard/tests/test_foodbank.py deleted file mode 100644 index ae13bd4..0000000 --- a/python_pkg/diet_guard/tests/test_foodbank.py +++ /dev/null @@ -1,190 +0,0 @@ -"""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 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/python_pkg/diet_guard/tests/test_fuzzy.py b/python_pkg/diet_guard/tests/test_fuzzy.py deleted file mode 100644 index cc04b3a..0000000 --- a/python_pkg/diet_guard/tests/test_fuzzy.py +++ /dev/null @@ -1,46 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_gate.py b/python_pkg/diet_guard/tests/test_gate.py deleted file mode 100644 index 8cb6b95..0000000 --- a/python_pkg/diet_guard/tests/test_gate.py +++ /dev/null @@ -1,79 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_gatelock.py b/python_pkg/diet_guard/tests/test_gatelock.py deleted file mode 100644 index 4ad7a0c..0000000 --- a/python_pkg/diet_guard/tests/test_gatelock.py +++ /dev/null @@ -1,308 +0,0 @@ -"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window. - -Construction, MealGate's gatelock wiring (LockConfig choice, hooks), and the -shared module-level helpers. The fullscreen/grab/VT-disable mechanics -themselves are tested in the ``gatelock`` package, not here. The -nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the -functional fake ``tk`` widgets and the ``gate`` fixture live in -``conftest.py`` and are shared by both files. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest - -from python_pkg.diet_guard import ( - _gatelock, - _gatelock_support, - _gatelock_ui, -) -from python_pkg.diet_guard._budget import seal_budget -from python_pkg.diet_guard._gatelock import ( - MealGate, - _pending_slots, - acquire_gate_lock, - release_gate_lock, -) -from python_pkg.diet_guard._gatelock_core import _safe_float -from python_pkg.diet_guard._gatelock_nutrition import _format_preview -from python_pkg.diet_guard._gatelock_support import wait_for_display -from python_pkg.diet_guard._gatelock_ui import DEFAULT_PER_GRAMS -from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS -from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition - -# -------------------------------------------------------------------------- -# 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() - - -# -------------------------------------------------------------------------- -# 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, grams basis, and a soft lock.""" - assert gate.demo_mode is True - assert gate._vars.unit.get() == "grams" - assert gate._lock._config.mode == "soft" - - def test_production_builds(self) -> None: - """A production gate builds with a hard lock config.""" - with patch.object(_gatelock, "tk", _FAKE_TK): - gate = MealGate(demo_mode=False) - assert gate.demo_mode is False - assert gate._lock._config.mode == "hard" - - -# -------------------------------------------------------------------------- -# Form logic -# -------------------------------------------------------------------------- - - -class TestFormBasics: - """Field helpers and the numeric validator.""" - - def test_numeric_validator(self) -> None: - """Blank and numbers are allowed; words are not.""" - assert _gatelock_ui.is_numeric_or_blank("") - assert _gatelock_ui.is_numeric_or_blank("12.5") - assert not _gatelock_ui.is_numeric_or_blank("abc") - - def test_desc_get_set(self, gate: MealGate) -> None: - """The description round-trips through its helpers, trimmed.""" - 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._widgets.macros.kcal.insert(0, "abc") - assert gate._macro_values() is None - - -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._widgets.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._widgets.per_entry.delete(0) - gate._vars.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._widgets.per_entry.delete(0) - gate._vars.unit.set("items") - gate._set_desc("mystery") - assert gate._basis_grams() == DEFAULT_ITEM_GRAMS - - def test_basis_grams_default(self, gate: MealGate) -> None: - """Grams mode with a blank per uses the per-100 g default.""" - gate._widgets.per_entry.delete(0) - assert gate._basis_grams() == DEFAULT_PER_GRAMS - - 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._vars.unit.set("items") - gate._set_desc("apple") - gate._set_entry(gate._widgets.per_entry, "182") - gate._set_entry(gate._widgets.amount_entry, "5") - assert gate._eaten_grams() == 5 * 182 - - def test_amount_change_refreshes(self, gate: MealGate) -> None: - """Changing the amount recomputes the preview.""" - gate._set_entry(gate._widgets.macros.kcal, "100") - gate._set_entry(gate._widgets.amount_entry, "200") - gate._on_amount_change(None) - assert gate._vars.preview.get() - - def test_projection_else_without_item(self, gate: MealGate) -> None: - """With a budget but no priced item, no after-this-item is shown.""" - seal_budget(2000) - gate._refresh_projection() - text = gate._vars.projection.get() - assert "left" in text - assert "after this item" not in text - - def test_keyrelease_grams_mode(self, gate: MealGate) -> None: - """In grams mode the per-item weight is not touched on keyrelease.""" - gate._vars.unit.set("grams") - gate._set_desc("apple") - gate._on_desc_keyrelease(None) - - def test_keyrelease_items_unknown(self, gate: MealGate) -> None: - """An unknown item in items mode leaves the per field unchanged.""" - gate._vars.unit.set("items") - gate._set_desc("zzzz") - gate._on_desc_keyrelease(None) - - def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None: - """A grams-mode pick does not overwrite an amount already typed.""" - gate._set_entry(gate._widgets.amount_entry, "50") - gate._apply_reference(_nutrition(100, 100)) - assert gate._widgets.amount_entry.get() == "50" - - -class TestLockDelegation: - """MealGate's gatelock wiring: hooks delegate, run()/close() delegate.""" - - def test_on_focus_ready_focuses_desc_text(self, gate: MealGate) -> None: - """on_focus_ready puts keyboard focus on the description box.""" - gate._widgets.desc_text.focus_force = MagicMock() - gate.on_focus_ready() - gate._widgets.desc_text.focus_force.assert_called_once() - - def test_on_close_is_a_noop(self, gate: MealGate) -> None: - """on_close has no hardware/state to release; must not raise.""" - gate.on_close() - - def test_callback_error_status(self, gate: MealGate) -> None: - """An unexpected callback error surfaces a recoverable message.""" - gate.on_callback_error() - assert "went wrong" in gate._vars.status.get() - - def test_run_delegates_to_lock(self, gate: MealGate) -> None: - """run() hands off to the owned LockWindow.""" - with patch.object(gate._lock, "run") as mock_run: - gate.run() - mock_run.assert_called_once_with() - - def test_close_delegates_to_lock(self, gate: MealGate) -> None: - """close() hands off to the owned LockWindow.""" - with patch.object(gate._lock, "close") as mock_close: - gate.close() - mock_close.assert_called_once_with() - - -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_support, "tk", fake_tk): - assert _gatelock_support._display_is_ready() is True - fake_tk.Tk.return_value.destroy.assert_called_once() - - def test_not_ready_on_tclerror(self) -> None: - """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_support, "tk", fake_tk): - assert _gatelock_support._display_is_ready() is False - - def test_wait_returns_immediately_when_ready(self) -> None: - """A display ready on the first probe returns at once and never sleeps.""" - sleep = MagicMock() - with patch.object(_gatelock_support, "_display_is_ready", return_value=True): - ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0)) - assert ready is True - sleep.assert_not_called() - - 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_support, "_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_support, "_display_is_ready", return_value=False): - assert wait_for_display(sleep=sleep, monotonic=monotonic) is False - sleep.assert_not_called() diff --git a/python_pkg/diet_guard/tests/test_gatelock_mealflow.py b/python_pkg/diet_guard/tests/test_gatelock_mealflow.py deleted file mode 100644 index 3ff9173..0000000 --- a/python_pkg/diet_guard/tests/test_gatelock_mealflow.py +++ /dev/null @@ -1,425 +0,0 @@ -"""Tests for the nutrition model, lookup, and meal-building flow of MealGate. - -Covers :mod:`._gatelock_nutrition` (reference -> total maths, suggestions, -unit toggling) and :mod:`._gatelock_mealflow` (submit/lookup/record, the -dashboard, and multi-item meals). The functional fake ``tk`` widgets and the -``gate`` fixture live in ``conftest.py`` and are shared with -:mod:`test_gatelock`. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import patch - -from python_pkg.diet_guard import _gatelock_mealflow -from python_pkg.diet_guard._budget import seal_budget -from python_pkg.diet_guard._meal import MealItem -from python_pkg.diet_guard._state import log_meal -from python_pkg.diet_guard.tests.conftest import _nutrition - -if TYPE_CHECKING: - from python_pkg.diet_guard._gatelock import MealGate - - -class TestReferenceModel: - """The reference -> total nutrition computation.""" - - def test_reference_none_without_calories(self, gate: MealGate) -> None: - """No calories typed means no reference yet.""" - assert gate._reference_nutrition() is None - - def test_current_is_reference_without_amount(self, gate: MealGate) -> None: - """With calories but no amount, the reference stands in as the total.""" - gate._widgets.macros.kcal.insert(0, "200") - current = gate._current_nutrition() - assert current is not None - assert current.kcal == 200 - - def test_current_scales_with_amount(self, gate: MealGate) -> None: - """Grams eaten scale the per-100 g reference into the total.""" - gate._widgets.macros.kcal.insert(0, "200") - gate._widgets.amount_entry.insert(0, "200") - current = gate._current_nutrition() - assert current is not None - assert current.kcal == 400 - - -class TestSuggestions: - """Autocomplete population and selection.""" - - def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None: - """In items mode, typing a staple fills the per-item weight.""" - gate._vars.unit.set("items") - gate._set_desc("apple") - gate._on_desc_keyrelease(None) - assert gate._widgets.per_entry.get() == "182" - - def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None: - """Picking a banked suggestion adopts its name and macros.""" - gate._state.suggestions = [("apple pie", _nutrition(300, 120))] - gate._state.suggestion_mode = "bank" - gate._widgets.suggestion_box.selection_set(0) - gate._on_suggestion_select(None) - assert gate._get_desc() == "apple pie" - assert gate._widgets.macros.kcal.get() == "300" - - def test_select_candidate_keeps_description(self, gate: MealGate) -> None: - """An OFF candidate fills macros but not the typed description.""" - gate._set_desc("my dish") - gate._state.suggestions = [("openfoodfacts: X", _nutrition(250, 100))] - gate._state.suggestion_mode = "candidates" - gate._widgets.suggestion_box.selection_set(0) - gate._on_suggestion_select(None) - assert gate._get_desc() == "my dish" - - def test_select_no_selection(self, gate: MealGate) -> None: - """No selection is a no-op.""" - gate._on_suggestion_select(None) - - def test_select_out_of_range(self, gate: MealGate) -> None: - """A stale selection index beyond the list is ignored.""" - gate._state.suggestions = [] - gate._widgets.suggestion_box.selection_set(5) - gate._on_suggestion_select(None) - - -class TestUnitToggle: - """Switching the grams/items basis.""" - - def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None: - """A picked food is re-expressed per item, then back per 100 g.""" - gate._apply_reference(_nutrition(52, 100), name="apple") - gate._vars.unit.set("items") - gate._on_unit_change("items") - per_item = gate._widgets.macros.kcal.get() - gate._vars.unit.set("grams") - gate._on_unit_change("grams") - assert gate._widgets.macros.kcal.get() == "52" - assert per_item != "52" - - def test_toggle_without_reference_clears(self, gate: MealGate) -> None: - """With no picked food, a toggle clears the macro fields.""" - gate._widgets.macros.kcal.insert(0, "123") - gate._state.last_reference = None - gate._vars.unit.set("items") - gate._on_unit_change("items") - assert gate._widgets.macros.kcal.get() == "" - - def test_macro_edit_drops_reference(self, gate: MealGate) -> None: - """Hand-editing a macro invalidates the stored reference.""" - gate._state.last_reference = _nutrition() - gate._on_macro_edit(None) - assert gate._state.last_reference is None - - -class TestSubmit: - """The two-step submit (look up, then log).""" - - def test_empty_description(self, gate: MealGate) -> None: - """Submitting with no description prompts for one.""" - gate._on_submit() - assert "Type what you ate" in gate._vars.status.get() - - def test_non_numeric_macros(self, gate: MealGate) -> None: - """Non-numeric macros are rejected before logging.""" - gate._set_desc("apple") - gate._widgets.macros.kcal.insert(0, "abc") - gate._on_submit() - assert "must be numbers" in gate._vars.status.get() - - def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None: - """A blank calorie field looks the food up rather than logging.""" - gate._set_desc("apple") - with patch.object(gate, "_begin_lookup") as lookup: - gate._on_submit() - lookup.assert_called_once() - - def test_defensive_none_nutrition(self, gate: MealGate) -> None: - """A calorie value but unresolvable nutrition prompts again (guard).""" - gate._set_desc("apple") - gate._widgets.macros.kcal.insert(0, "200") - with patch.object(gate, "_current_nutrition", return_value=None): - gate._on_submit() - assert "Enter the calories" in gate._vars.status.get() - - def test_valid_submit_records(self, gate: MealGate) -> None: - """A described, priced meal is recorded.""" - gate._set_desc("apple") - gate._widgets.macros.kcal.insert(0, "95") - with patch.object(gate, "_record") as record: - gate._on_submit() - record.assert_called_once() - - def test_on_return_submits(self, gate: MealGate) -> None: - """Enter in a numeric field submits.""" - with patch.object(gate, "_on_submit") as submit: - gate._on_return(None) - submit.assert_called_once() - - -class TestLookup: - """Step one: filling the form from a lookup.""" - - def test_no_candidates(self, gate: MealGate) -> None: - """No match asks for a manual value.""" - gate._set_desc("nonsense") - with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]): - gate._begin_lookup("nonsense") - assert "Couldn't look that up" in gate._vars.status.get() - - def test_single_candidate(self, gate: MealGate) -> None: - """A single match fills the fields and invites review.""" - with patch.object( - _gatelock_mealflow, - "lookup_candidates", - return_value=[("apple", _nutrition(95, 100))], - ): - gate._begin_lookup("apple") - assert "Review the values" in gate._vars.status.get() - - def test_multiple_candidates(self, gate: MealGate) -> None: - """Several matches invite picking another.""" - with patch.object( - _gatelock_mealflow, - "lookup_candidates", - return_value=[ - ("a", _nutrition(95, 100)), - ("b", _nutrition(120, 100)), - ], - ): - gate._begin_lookup("apple") - assert "pick another" in gate._vars.status.get() - - -class TestRecord: - """Logging a meal and advancing the slot walk.""" - - def test_demo_logs_without_slot(self, gate: MealGate) -> None: - """A demo record banks the food but tags no real slot.""" - gate._pending = [8] - with patch.object(_gatelock_mealflow, "log_meal") as log: - gate._record("apple", _nutrition(95, 100)) - assert log.call_args.args[2] is None - - def test_last_slot_unlocks(self, gate: MealGate) -> None: - """Recording the final pending slot triggers the unlock.""" - gate._pending = [8] - with ( - patch.object(_gatelock_mealflow, "log_meal"), - patch.object(_gatelock_mealflow, "remember_food"), - patch.object(gate, "_unlock") as unlock, - ): - gate._record("apple", _nutrition(95, 100)) - unlock.assert_called_once() - - def test_more_slots_continue(self, gate: MealGate) -> None: - """With slots remaining, the form clears and prompts the next.""" - gate._pending = [8, 12] - with ( - patch.object(_gatelock_mealflow, "log_meal"), - patch.object(_gatelock_mealflow, "remember_food"), - ): - gate._record("apple", _nutrition(95, 100)) - assert gate._pending == [12] - assert "next meal" in gate._vars.status.get() - - def test_unlock_schedules_close(self, gate: MealGate) -> None: - """Unlock sets the closing status and schedules teardown.""" - gate._unlock("logged X") - assert "unlocking" in gate._vars.status.get() - - -class TestDashboard: - """The running calorie/macro panel.""" - - def test_headline_with_budget(self, gate: MealGate) -> None: - """A sealed budget shows consumed/target/remaining.""" - seal_budget(2000) - gate._refresh_dashboard() - assert "left" in gate._vars.cal_headline.get() - - def test_headline_without_budget(self, gate: MealGate) -> None: - """With no budget, only today's total is shown.""" - gate._refresh_dashboard() - assert "kcal today" in gate._vars.cal_headline.get() - - def test_dashboard_lists_entries(self, gate: MealGate) -> None: - """Logged entries appear in the detail panel.""" - seal_budget(2000, weight_kg=80) - log_meal("apple", _nutrition(95, 100), 8) - gate._refresh_dashboard() - text = gate._vars.dashboard.get() - assert "apple" in text - assert "protein" in text - - def test_dashboard_empty(self, gate: MealGate) -> None: - """With nothing logged, the panel says so.""" - gate._refresh_dashboard() - assert "nothing logged yet" in gate._vars.dashboard.get() - - def test_slot_header_variants(self, gate: MealGate) -> None: - """The header covers none / one / several pending slots.""" - gate._pending = [] - gate._refresh_slot_header() - assert "All meals logged" in gate._vars.slot_header.get() - gate._pending = [8] - gate._refresh_slot_header() - assert "Log your" in gate._vars.slot_header.get() - gate._pending = [8, 12] - gate._refresh_slot_header() - assert "remaining" in gate._vars.slot_header.get() - - def test_projection_with_budget(self, gate: MealGate) -> None: - """The projection shows the after-this-item remaining when priced.""" - seal_budget(2000) - gate._widgets.macros.kcal.insert(0, "300") - gate._refresh_projection() - assert "after this item" in gate._vars.projection.get() - - -class TestMealFlow: - """Building and logging a multi-item composite meal.""" - - def test_meal_name_trimmed(self, gate: MealGate) -> None: - """The meal name is read back trimmed.""" - gate._widgets.meal_name_entry.insert(0, " dinner ") - assert gate._meal_name() == "dinner" - - def test_summary_empty_with_no_items(self, gate: MealGate) -> None: - """With no accumulated items the running summary is blank.""" - gate._refresh_meal_summary() - assert gate._vars.meal_summary.get() == "" - - def test_summary_lists_items_and_total(self, gate: MealGate) -> None: - """The summary shows the item names and the running calorie total.""" - gate._state.meal_items = [ - MealItem("salad", _nutrition(80, 120)), - MealItem("chicken", _nutrition(330, 200)), - ] - gate._refresh_meal_summary() - summary = gate._vars.meal_summary.get() - assert "salad, chicken" in summary - assert "410 kcal" in summary - - def test_add_item_requires_description(self, gate: MealGate) -> None: - """Adding with no description prompts for one.""" - gate._on_add_item() - assert "Type the item first" in gate._vars.status.get() - - def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None: - """Non-numeric macros are rejected before adding.""" - gate._set_desc("salad") - gate._widgets.macros.kcal.insert(0, "abc") - gate._on_add_item() - assert "must be numbers" in gate._vars.status.get() - - def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None: - """A blank calorie field looks the item up rather than adding.""" - gate._set_desc("salad") - with patch.object(gate, "_begin_lookup") as lookup: - gate._on_add_item() - lookup.assert_called_once() - - def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None: - """A priced item that will not resolve prompts again (guard).""" - gate._set_desc("salad") - gate._widgets.macros.kcal.insert(0, "80") - with patch.object(gate, "_current_nutrition", return_value=None): - gate._on_add_item() - assert "add the item" in gate._vars.status.get() - - def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None: - """A valid item is appended, the form clears, the meal name is kept.""" - gate._widgets.meal_name_entry.insert(0, "dinner") - gate._set_desc("salad") - gate._widgets.macros.kcal.insert(0, "80") - gate._on_add_item() - assert len(gate._state.meal_items) == 1 - assert gate._state.meal_items[0].name == "salad" - assert gate._get_desc() == "" - assert gate._meal_name() == "dinner" - assert "Added salad" in gate._vars.status.get() - - def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None: - """Submitting an empty form with items finalizes the meal.""" - gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))] - with patch.object(gate, "_log_meal") as log_meal_: - gate._on_submit() - log_meal_.assert_called_once() - - def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None: - """A filled form plus existing items adds the form item, then logs.""" - gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))] - gate._set_desc("rice") - gate._widgets.macros.kcal.insert(0, "260") - with patch.object(gate, "_log_meal") as log_meal_: - gate._on_submit() - assert len(gate._state.meal_items) == 2 - assert gate._state.meal_items[1].name == "rice" - log_meal_.assert_called_once() - - def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None: - """Logging a meal banks it under the typed name and advances the slot.""" - gate._pending = [8, 12] - gate._widgets.meal_name_entry.insert(0, "dinner") - gate._state.meal_items = [ - MealItem("salad", _nutrition(80, 120)), - MealItem("chicken", _nutrition(330, 200)), - ] - with ( - patch.object( - _gatelock_mealflow, - "remember_meal", - return_value=_nutrition(410, 320), - ) as remember, - patch.object(_gatelock_mealflow, "log_meal") as log, - ): - gate._log_meal() - assert remember.call_args.args[0] == "dinner" - assert log.call_args.args[0] == "dinner" - assert gate._state.meal_items == [] - assert gate._pending == [12] - - def test_log_meal_uses_default_name(self, gate: MealGate) -> None: - """A blank meal name falls back to the default.""" - gate._pending = [8, 12] - gate._state.meal_items = [MealItem("soup", _nutrition(150, 300))] - with ( - patch.object( - _gatelock_mealflow, - "remember_meal", - return_value=_nutrition(150, 300), - ) as remember, - patch.object(_gatelock_mealflow, "log_meal"), - ): - gate._log_meal() - assert remember.call_args.args[0] == _gatelock_mealflow._DEFAULT_MEAL_NAME - - def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None: - """A demo gate tags logs with no real slot.""" - gate._pending = [8] - assert gate._slot_for_log() is None - - def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None: - """A production gate tags logs with the current slot.""" - gate.demo_mode = False - gate._pending = [12] - assert gate._slot_for_log() == 12 - - def test_clear_inputs_discards_meal(self, gate: MealGate) -> None: - """Clearing between slots drops the in-progress meal and its name.""" - gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))] - gate._widgets.meal_name_entry.insert(0, "dinner") - gate._vars.meal_summary.set("something") - gate._clear_inputs() - assert gate._state.meal_items == [] - assert gate._meal_name() == "" - assert gate._vars.meal_summary.get() == "" - - def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None: - """Finishing the final slot triggers unlock.""" - gate._pending = [20] - with patch.object(gate, "_unlock") as unlock: - gate._finish_slot("done") - unlock.assert_called_once() diff --git a/python_pkg/diet_guard/tests/test_main.py b/python_pkg/diet_guard/tests/test_main.py deleted file mode 100644 index 984f684..0000000 --- a/python_pkg/diet_guard/tests/test_main.py +++ /dev/null @@ -1,21 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_meal.py b/python_pkg/diet_guard/tests/test_meal.py deleted file mode 100644 index b013102..0000000 --- a/python_pkg/diet_guard/tests/test_meal.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_portions.py b/python_pkg/diet_guard/tests/test_portions.py deleted file mode 100644 index ce195db..0000000 --- a/python_pkg/diet_guard/tests/test_portions.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_resolve.py b/python_pkg/diet_guard/tests/test_resolve.py deleted file mode 100644 index b54613b..0000000 --- a/python_pkg/diet_guard/tests/test_resolve.py +++ /dev/null @@ -1,141 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_slots.py b/python_pkg/diet_guard/tests/test_slots.py deleted file mode 100644 index 4baf5f3..0000000 --- a/python_pkg/diet_guard/tests/test_slots.py +++ /dev/null @@ -1,111 +0,0 @@ -"""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/python_pkg/diet_guard/tests/test_state.py b/python_pkg/diet_guard/tests/test_state.py deleted file mode 100644 index 8773aab..0000000 --- a/python_pkg/diet_guard/tests/test_state.py +++ /dev/null @@ -1,248 +0,0 @@ -"""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() == {}