mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 15:43:06 +02:00
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.
This commit is contained in:
parent
247607e8c3
commit
091045fd67
@ -10,6 +10,7 @@ Extracted to their own repos:
|
|||||||
|
|
||||||
- [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer)
|
- [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer)
|
||||||
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
|
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
|
||||||
|
- [`diet-guard`](https://github.com/kuhyx/diet-guard)
|
||||||
|
|
||||||
Archived / unmaintained projects live in the sibling repository
|
Archived / unmaintained projects live in the sibling repository
|
||||||
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
||||||
|
|||||||
46
docs/superpowers/evidence/remove-diet-guard-2026-06-22.json
Normal file
46
docs/superpowers/evidence/remove-diet-guard-2026-06-22.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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.
|
|
||||||
"""
|
|
||||||
@ -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())
|
|
||||||
@ -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)
|
|
||||||
@ -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 <number> to log it manually.",
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
log_meal(description, nutrition, current_slot(now_local()))
|
|
||||||
remember_food(description, nutrition)
|
|
||||||
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
|
|
||||||
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"
|
|
||||||
_emit(
|
|
||||||
f"logged: {description} {nutrition.kcal:g} kcal "
|
|
||||||
f"({macro_str}) [{nutrition.source}, {portion_str}]",
|
|
||||||
)
|
|
||||||
_print_summary()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _print_slot_status() -> None:
|
|
||||||
"""Print each meal slot as logged / DUE / upcoming for today."""
|
|
||||||
logged = logged_slots_today()
|
|
||||||
due = set(due_slots())
|
|
||||||
parts: list[str] = []
|
|
||||||
for slot in day_slots():
|
|
||||||
if slot in logged:
|
|
||||||
mark = "logged"
|
|
||||||
elif slot in due:
|
|
||||||
mark = "DUE"
|
|
||||||
else:
|
|
||||||
mark = "upcoming"
|
|
||||||
parts.append(f"{slot_label(slot)} {mark}")
|
|
||||||
_emit("slots: " + " ".join(parts))
|
|
||||||
|
|
||||||
|
|
||||||
def _print_macro_status() -> None:
|
|
||||||
"""Print today's macros so far, with the protein target when it is known.
|
|
||||||
|
|
||||||
Mirrors the gate's dashboard on the command line so "how am I doing" is
|
|
||||||
answerable without opening the window. The protein target only appears once
|
|
||||||
the budget has been initialized with a body weight (see ``init``).
|
|
||||||
"""
|
|
||||||
protein, carbs, fat = today_total_macros()
|
|
||||||
line = f"macros: P{protein:g} C{carbs:g} F{fat:g} g"
|
|
||||||
target = protein_target_g()
|
|
||||||
if target is not None:
|
|
||||||
remaining = round(target - protein, 1)
|
|
||||||
line += f" - protein {protein:g}/{target:g} g ({remaining:g} left)"
|
|
||||||
_emit(line)
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_status() -> int:
|
|
||||||
"""Print today's entries, per-slot status, macros, and the budget remaining."""
|
|
||||||
entries = today_entries()
|
|
||||||
for entry in entries:
|
|
||||||
_print_entry_line(entry)
|
|
||||||
if entries:
|
|
||||||
_emit("-" * 48)
|
|
||||||
_print_slot_status()
|
|
||||||
_print_summary()
|
|
||||||
_print_macro_status()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_undo() -> int:
|
|
||||||
"""Remove today's most recent entry and report what was removed."""
|
|
||||||
removed = undo_last_today()
|
|
||||||
if removed is None:
|
|
||||||
_emit("nothing to undo today.")
|
|
||||||
return 0
|
|
||||||
desc = str(removed.get("desc", "?"))
|
|
||||||
_emit(f"removed: {desc} ({entry_kcal(removed):g} kcal)")
|
|
||||||
_print_summary()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_gate(*, check: bool, demo: bool) -> int:
|
|
||||||
"""Run the log-to-unlock gate.
|
|
||||||
|
|
||||||
Three modes: ``--check`` is a headless decision (no window) whose exit code
|
|
||||||
a timer reads; ``--demo`` always shows a safe demo window; bare ``gate``
|
|
||||||
shows the real lock only when one is due. A flock guard stops a second
|
|
||||||
window from stacking on top of the first, and a window-opening mode first
|
|
||||||
waits for the X display so a session-start launch never crashes unshown.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
check: Headless mode -- print and return an exit code, open no window.
|
|
||||||
demo: Use safe demo mode (local grab + close button) for the window.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
For ``--check``: 0 if not due, 1 if a lock is due. Otherwise 0.
|
|
||||||
"""
|
|
||||||
if check:
|
|
||||||
due = gate_is_due()
|
|
||||||
_emit("due (a lock is warranted)" if due else "ok (no lock needed)")
|
|
||||||
return 1 if due else 0
|
|
||||||
if not demo and not gate_is_due():
|
|
||||||
_emit("ok - no lock needed right now.")
|
|
||||||
return 0
|
|
||||||
handle = acquire_gate_lock()
|
|
||||||
if handle is None:
|
|
||||||
_emit("the gate is already running.")
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
# At session start the timer can fire before the X display/auth cookie
|
|
||||||
# is ready; wait it out so the window opens instead of crashing on a
|
|
||||||
# "couldn't connect to display" TclError (see _gatelock.wait_for_display).
|
|
||||||
if not wait_for_display():
|
|
||||||
_emit("display not ready yet; will retry on the next timer tick.")
|
|
||||||
return 0
|
|
||||||
MealGate(demo_mode=demo).run()
|
|
||||||
finally:
|
|
||||||
release_gate_lock(handle)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
"""Dispatch a diet_guard subcommand.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
argv: Argument list (defaults to ``sys.argv[1:]``).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A process exit code (0 on success).
|
|
||||||
"""
|
|
||||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
||||||
if args.command == "init":
|
|
||||||
return _cmd_init()
|
|
||||||
if args.command == "ate":
|
|
||||||
macros = _ManualMacros(
|
|
||||||
kcal=args.kcal,
|
|
||||||
protein=args.protein,
|
|
||||||
carbs=args.carbs,
|
|
||||||
fat=args.fat,
|
|
||||||
)
|
|
||||||
portion = _Portion(
|
|
||||||
grams=args.grams,
|
|
||||||
count=args.count,
|
|
||||||
per_grams=args.per,
|
|
||||||
)
|
|
||||||
return _cmd_ate(args.description, portion, macros)
|
|
||||||
if args.command == "status":
|
|
||||||
return _cmd_status()
|
|
||||||
if args.command == "gate":
|
|
||||||
return _cmd_gate(check=args.check, demo=args.demo)
|
|
||||||
return _cmd_undo()
|
|
||||||
@ -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"
|
|
||||||
@ -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)
|
|
||||||
@ -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]
|
|
||||||
]
|
|
||||||
@ -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)
|
|
||||||
@ -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."
|
|
||||||
@ -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("<KeyRelease>", self._on_desc_keyrelease)
|
|
||||||
widgets.desc_text.bind("<Return>", self._on_desc_return)
|
|
||||||
widgets.suggestion_box.bind(
|
|
||||||
"<<ListboxSelect>>",
|
|
||||||
self._on_suggestion_select,
|
|
||||||
)
|
|
||||||
for entry in (widgets.amount_entry, widgets.per_entry):
|
|
||||||
entry.bind("<KeyRelease>", self._on_amount_change)
|
|
||||||
entry.bind("<Return>", self._on_return)
|
|
||||||
for entry in self._macro_entries():
|
|
||||||
entry.bind("<Return>", self._on_return)
|
|
||||||
entry.bind("<KeyRelease>", 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"
|
|
||||||
@ -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}")
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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)
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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: <name>"``), or None.
|
|
||||||
"""
|
|
||||||
staple = _best_staple(description)
|
|
||||||
return _staple_to_nutrition(staple) if staple is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def suggest_staples(
|
|
||||||
query: str,
|
|
||||||
limit: int = 6,
|
|
||||||
) -> list[tuple[str, Nutrition]]:
|
|
||||||
"""Return staples whose name matches ``query``, best match first.
|
|
||||||
|
|
||||||
Used to surface built-in whole foods in the gate's live autocomplete (so
|
|
||||||
typing "apple" suggests the staple immediately, without a separate lookup
|
|
||||||
step), alongside the user's banked foods.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Free-text the user has typed so far.
|
|
||||||
limit: Maximum number of suggestions to return.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(name, per-100 g Nutrition)`` pairs, ranked, at most ``limit`` long.
|
|
||||||
"""
|
|
||||||
key = query.strip().casefold()
|
|
||||||
if not key:
|
|
||||||
return []
|
|
||||||
scored: list[tuple[float, Staple]] = []
|
|
||||||
for staple in _STAPLES:
|
|
||||||
score = match_score(key, staple.name)
|
|
||||||
if score >= _MATCH_THRESHOLD:
|
|
||||||
scored.append((score, staple))
|
|
||||||
scored.sort(key=lambda item: item[0], reverse=True)
|
|
||||||
return [(staple.name, _staple_to_nutrition(staple)) for _, staple in scored[:limit]]
|
|
||||||
@ -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]
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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")
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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() == {}
|
|
||||||
@ -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")
|
|
||||||
@ -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."
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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") == []
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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() == {}
|
|
||||||
Loading…
Reference in New Issue
Block a user