mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 12:03:08 +02:00
feat(diet_guard): add meal-logging screen-lock gate with trigger fix
Add the diet_guard package: a screen-locking meal-logging gate that fires on 4-hour slots (08/12/16/20) and records calories/macros, persisting an autocompleting food bank. - Trigger fix: the systemd timer fires at session start (Persistent=true) before lightdm has written ~/.Xauthority, so the gate crashed with a TclError instead of locking the screen. Add wait_for_display() / _display_is_ready() in _gatelock.py and wire it into _cli._cmd_gate so the gate retries on the next tick instead of crashing; add Environment=XAUTHORITY=%h/.Xauthority to the service as belt-and-suspenders. - Food-bank hardening: a transiently corrupt food_bank.json was warned about on every keystroke and then silently overwritten (data loss). _read_bank now quarantines it via _quarantine_corrupt_bank() (warn-once + timestamped backup) before starting fresh. - Multi-item meals: new _meal.py (MealItem, meal_total, MEAL_SOURCE), remember_meal() + _upsert() in _foodbank.py, and a "+ Add item" control in the gate that logs both the individual items and the composite meal. - Bundle resolve_nutrition's manual macros into a ManualMacros dataclass to stay within the argument-count limit. diet_guard at 100% branch coverage; full pre-commit suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
400f89b469
12
diet_guard/__init__.py
Normal file
12
diet_guard/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""diet_guard: log what you eat, see the daily number, gate the desktop.
|
||||
|
||||
The package has three layers, built in order:
|
||||
|
||||
* the tracker (this milestone): a low-friction CLI that estimates the
|
||||
calories of a free-text meal and appends a tamper-evident entry to a
|
||||
per-day log;
|
||||
* the gate (next): a screen-lock that will not dismiss until a recent meal
|
||||
is logged -- the "log-to-unlock" enforcement;
|
||||
* the escalation (later): a daily report that tightens the other personal
|
||||
enforcers (games, PC uptime) when logging is skipped or the budget blown.
|
||||
"""
|
||||
10
diet_guard/__main__.py
Normal file
10
diet_guard/__main__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Module entry point: ``python -m python_pkg.diet_guard``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from python_pkg.diet_guard._cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
317
diet_guard/_budget.py
Normal file
317
diet_guard/_budget.py
Normal file
@ -0,0 +1,317 @@
|
||||
"""Hidden, tamper-hardened daily calorie budget for diet_guard.
|
||||
|
||||
This mirrors how ``phone_focus_mode`` hides the home GPS coordinates: the real
|
||||
number never lives in committed source and is never printed. It is computed
|
||||
once from biometrics at ``init`` time, written to a runtime-only file in the
|
||||
XDG data dir (git-ignored, exactly like the phone's ``config_secrets.sh``), and
|
||||
locked with ``chattr +i`` so changing it costs a deliberate ``sudo`` step.
|
||||
|
||||
Honest threat model (the same one the phone accepts):
|
||||
|
||||
* The point is **friction in a weak moment**, not cryptographic secrecy. The
|
||||
Mifflin-St Jeor formula is right here in source and you know your own weight,
|
||||
so a determined you can always recompute the number -- just as a determined
|
||||
you can root the phone and read the coordinates.
|
||||
* The shared HMAC key at ``/etc/workout-locker/hmac.key`` is world-readable, so
|
||||
the signature defeats a *naive* edit (and detects disk corruption) but not
|
||||
someone who reads the key and re-signs. The signature is tamper-*evidence*,
|
||||
not a tamper-*lock*.
|
||||
* The real lock is ``chattr +i``: removing the immutable bit needs root, which
|
||||
is the actual speed bump. The strongest layer of all is simply that the
|
||||
value is never displayed, so there is no on-screen anchor to fixate on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from python_pkg.diet_guard._constants import BUDGET_FILE
|
||||
from python_pkg.shared.log_integrity import compute_entry_hmac
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Schema version stored inside the sealed blob, so a future format change can be
|
||||
# detected rather than silently misread. v2 adds the optional body weight (``w``)
|
||||
# used to derive a protein target; v1 seals (budget only) still read correctly.
|
||||
_SEAL_VERSION = 2
|
||||
|
||||
# A medically sane lower bound. Even an aggressive deficit must not seal a
|
||||
# starvation-level target, so the computed value is floored here.
|
||||
_MIN_SANE_BUDGET = 1200
|
||||
|
||||
# Daily protein target for an active adult holding muscle on a deficit, in grams
|
||||
# per kg of body weight. Used only to show a target in the dashboard; it has no
|
||||
# part in the sealed calorie budget maths.
|
||||
PROTEIN_G_PER_KG = 1.8
|
||||
|
||||
# Probe payload used only to check whether the shared HMAC key can be loaded.
|
||||
_KEY_PROBE: dict[str, object] = {"_probe": True}
|
||||
|
||||
|
||||
class BudgetError(Exception):
|
||||
"""Base class for all budget-access failures."""
|
||||
|
||||
|
||||
class BudgetNotInitializedError(BudgetError):
|
||||
"""Raised when no sealed budget exists yet (``init`` never run)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with a fixed, side-effect-free message."""
|
||||
super().__init__("daily budget has not been initialized")
|
||||
|
||||
|
||||
class BudgetSealBrokenError(BudgetError):
|
||||
"""Raised when the sealed budget is unreadable, corrupt, or tampered."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with a fixed, side-effect-free message."""
|
||||
super().__init__("daily budget seal is broken (tampered or corrupted)")
|
||||
|
||||
|
||||
class BudgetLockedError(BudgetError):
|
||||
"""Raised when the budget file is immutable and cannot be rewritten."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with a fixed, side-effect-free message."""
|
||||
super().__init__("daily budget file is locked (chattr +i)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Biometrics:
|
||||
"""Body metrics that feed the Mifflin-St Jeor budget formula.
|
||||
|
||||
Grouped into one value object so the budget calculation stays under the
|
||||
repo's five-argument lint ceiling and so the inputs travel together.
|
||||
|
||||
Attributes:
|
||||
weight_kg: Body mass in kilograms.
|
||||
height_cm: Height in centimetres.
|
||||
age_years: Age in years.
|
||||
is_male: True for the male BMR constant (+5), False for female (-161).
|
||||
"""
|
||||
|
||||
weight_kg: float
|
||||
height_cm: float
|
||||
age_years: float
|
||||
is_male: bool
|
||||
|
||||
|
||||
def mifflin_st_jeor_bmr(bio: Biometrics) -> float:
|
||||
"""Return resting metabolic rate via the Mifflin-St Jeor equation.
|
||||
|
||||
Args:
|
||||
bio: The person's body metrics.
|
||||
|
||||
Returns:
|
||||
Basal metabolic rate in kcal/day.
|
||||
"""
|
||||
base = 10.0 * bio.weight_kg + 6.25 * bio.height_cm - 5.0 * bio.age_years
|
||||
return base + 5.0 if bio.is_male else base - 161.0
|
||||
|
||||
|
||||
def compute_target_budget(
|
||||
bio: Biometrics,
|
||||
*,
|
||||
activity_factor: float,
|
||||
deficit_kcal: float,
|
||||
) -> int:
|
||||
"""Return the daily kcal target: TDEE minus a deficit, floored for safety.
|
||||
|
||||
TDEE (total daily energy expenditure) is the BMR scaled by an activity
|
||||
factor; subtracting a deficit yields a target that drives gradual loss.
|
||||
|
||||
Args:
|
||||
bio: The person's body metrics.
|
||||
activity_factor: Multiplier for daily activity (e.g. 1.2 sedentary,
|
||||
1.375 light, 1.55 moderate, 1.725 very active).
|
||||
deficit_kcal: Calories subtracted from TDEE for weight loss.
|
||||
|
||||
Returns:
|
||||
The target budget in kcal, never below ``_MIN_SANE_BUDGET``.
|
||||
"""
|
||||
bmr = mifflin_st_jeor_bmr(bio)
|
||||
tdee = bmr * activity_factor
|
||||
target = round(tdee - deficit_kcal)
|
||||
return max(target, _MIN_SANE_BUDGET)
|
||||
|
||||
|
||||
def _hmac_key_available() -> bool:
|
||||
"""Return True if the shared HMAC key can be loaded for signing."""
|
||||
return compute_entry_hmac(_KEY_PROBE) is not None
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
"""Return True if a sealed budget file exists on disk."""
|
||||
return BUDGET_FILE.exists()
|
||||
|
||||
|
||||
def lock_command() -> str:
|
||||
"""Return the shell command that makes the sealed budget immutable."""
|
||||
return f"sudo chattr +i {BUDGET_FILE}"
|
||||
|
||||
|
||||
def unlock_command() -> str:
|
||||
"""Return the shell command that clears the immutable bit before re-init."""
|
||||
return f"sudo chattr -i {BUDGET_FILE}"
|
||||
|
||||
|
||||
def seal_budget(value: int, *, weight_kg: float | None = None) -> None:
|
||||
"""Write ``value`` to the runtime budget file, base64-wrapped and signed.
|
||||
|
||||
The value is JSON-encoded, base64-wrapped (so a casual ``cat`` shows no
|
||||
recognizable number) and HMAC-signed (so a naive edit is detectable). The
|
||||
file is *not* made immutable here -- that needs root; the caller prints
|
||||
:func:`lock_command` for the user (or install.sh) to run.
|
||||
|
||||
Args:
|
||||
value: The computed daily budget in kcal.
|
||||
weight_kg: Body weight in kg to store alongside the budget, so a protein
|
||||
target can later be derived. Optional; omitting it seals a
|
||||
budget-only blob that reads back with no protein target.
|
||||
|
||||
Raises:
|
||||
BudgetLockedError: If the existing file is immutable (run
|
||||
:func:`unlock_command` first).
|
||||
"""
|
||||
inner: dict[str, object] = {"v": _SEAL_VERSION, "b": int(value)}
|
||||
if weight_kg is not None:
|
||||
inner["w"] = round(float(weight_kg), 1)
|
||||
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
||||
record: dict[str, object] = {"data": base64.b64encode(blob).decode("ascii")}
|
||||
signature = compute_entry_hmac(inner)
|
||||
if signature is not None:
|
||||
record["hmac"] = signature
|
||||
else:
|
||||
_logger.warning("HMAC key unavailable - sealing budget unsigned")
|
||||
|
||||
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with BUDGET_FILE.open("w") as handle:
|
||||
json.dump(record, handle)
|
||||
except PermissionError as exc:
|
||||
raise BudgetLockedError from exc
|
||||
|
||||
|
||||
def _decode_inner(record: object) -> dict[str, object]:
|
||||
"""Return the inner payload dict from a parsed seal record.
|
||||
|
||||
Raises:
|
||||
BudgetSealBrokenError: If the record shape or base64 is invalid.
|
||||
"""
|
||||
if not isinstance(record, dict):
|
||||
raise BudgetSealBrokenError
|
||||
data = record.get("data")
|
||||
if not isinstance(data, str):
|
||||
raise BudgetSealBrokenError
|
||||
try:
|
||||
inner = json.loads(base64.b64decode(data, validate=True))
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise BudgetSealBrokenError from exc
|
||||
if not isinstance(inner, dict):
|
||||
raise BudgetSealBrokenError
|
||||
return inner
|
||||
|
||||
|
||||
def _verify_signature(record: dict[str, object], inner: dict[str, object]) -> None:
|
||||
"""Check the seal's HMAC, mirroring the food log's degradation rules.
|
||||
|
||||
A present signature must verify. A missing signature is tolerated only on a
|
||||
system with no key at all; a stripped signature where a key *is* available
|
||||
means someone removed it to cheat.
|
||||
|
||||
Raises:
|
||||
BudgetSealBrokenError: If the signature is missing-but-keyed, or wrong.
|
||||
"""
|
||||
stored = record.get("hmac")
|
||||
if isinstance(stored, str):
|
||||
expected = compute_entry_hmac(inner)
|
||||
if expected is None or not hmac.compare_digest(stored, expected):
|
||||
raise BudgetSealBrokenError
|
||||
return
|
||||
if _hmac_key_available():
|
||||
raise BudgetSealBrokenError
|
||||
|
||||
|
||||
def _load_verified_inner() -> dict[str, object]:
|
||||
"""Read, decode, and integrity-check the sealed blob, returning its payload.
|
||||
|
||||
Returns:
|
||||
The inner payload dict (carrying ``b`` and, for v2 seals, ``w``).
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been sealed yet.
|
||||
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
||||
"""
|
||||
if not BUDGET_FILE.exists():
|
||||
raise BudgetNotInitializedError
|
||||
try:
|
||||
with BUDGET_FILE.open() as handle:
|
||||
record = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise BudgetSealBrokenError from exc
|
||||
inner = _decode_inner(record)
|
||||
# ``record`` is a dict here: _decode_inner rejects any non-dict already.
|
||||
_verify_signature(record, inner)
|
||||
return inner
|
||||
|
||||
|
||||
def daily_budget() -> int:
|
||||
"""Return the sealed daily budget, verifying integrity first.
|
||||
|
||||
This is the only way callers obtain the number, and they must use it for an
|
||||
internal decision (over/under) without printing it.
|
||||
|
||||
Returns:
|
||||
The daily kcal budget.
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been sealed yet.
|
||||
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
||||
"""
|
||||
inner = _load_verified_inner()
|
||||
value = inner.get("b")
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise BudgetSealBrokenError
|
||||
return value
|
||||
|
||||
|
||||
def budget_weight() -> float | None:
|
||||
"""Return the body weight stored with the budget, or None if unavailable.
|
||||
|
||||
Returns:
|
||||
The stored weight in kg, or None for a pre-v2 (budget-only) seal.
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been sealed yet.
|
||||
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
||||
"""
|
||||
inner = _load_verified_inner()
|
||||
value = inner.get("w")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def protein_target_g() -> float | None:
|
||||
"""Return the daily protein target in grams, or None if it cannot be derived.
|
||||
|
||||
Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns
|
||||
None -- rather than raising -- whenever the target is simply unavailable (no
|
||||
budget sealed, a pre-v2 seal without weight, or a broken seal), so the
|
||||
dashboard can show calories and quietly omit the protein line.
|
||||
|
||||
Returns:
|
||||
The protein target in grams, or None when weight is unknown.
|
||||
"""
|
||||
try:
|
||||
weight = budget_weight()
|
||||
except BudgetError:
|
||||
return None
|
||||
if weight is None:
|
||||
return None
|
||||
return round(weight * PROTEIN_G_PER_KG, 1)
|
||||
498
diet_guard/_cli.py
Normal file
498
diet_guard/_cli.py
Normal file
@ -0,0 +1,498 @@
|
||||
"""Command-line interface for diet_guard.
|
||||
|
||||
Examples:
|
||||
python -m python_pkg.diet_guard init
|
||||
python -m python_pkg.diet_guard ate "big mac"
|
||||
python -m python_pkg.diet_guard ate "two slices of pizza" --grams 240
|
||||
python -m python_pkg.diet_guard ate "protein shake" --kcal 180
|
||||
python -m python_pkg.diet_guard status
|
||||
python -m python_pkg.diet_guard undo
|
||||
|
||||
The daily budget lives outside the repo (so it is never exposed online) but is
|
||||
shown freely on this machine: ``status`` and each log print how many calories
|
||||
are left of the day's budget, plus which meal slots still need logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
|
||||
from python_pkg.diet_guard._budget import (
|
||||
Biometrics,
|
||||
BudgetLockedError,
|
||||
BudgetNotInitializedError,
|
||||
BudgetSealBrokenError,
|
||||
compute_target_budget,
|
||||
daily_budget,
|
||||
lock_command,
|
||||
protein_target_g,
|
||||
seal_budget,
|
||||
unlock_command,
|
||||
)
|
||||
from python_pkg.diet_guard._foodbank import remember_food
|
||||
from python_pkg.diet_guard._gate import due_slots, gate_is_due
|
||||
from python_pkg.diet_guard._gatelock import (
|
||||
MealGate,
|
||||
acquire_gate_lock,
|
||||
release_gate_lock,
|
||||
wait_for_display,
|
||||
)
|
||||
from python_pkg.diet_guard._portions import (
|
||||
DEFAULT_ITEM_GRAMS,
|
||||
estimate_unit_grams,
|
||||
)
|
||||
from python_pkg.diet_guard._resolve import ManualMacros, resolve_nutrition
|
||||
from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label
|
||||
from python_pkg.diet_guard._state import (
|
||||
entry_kcal,
|
||||
log_meal,
|
||||
logged_slots_today,
|
||||
now_local,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
undo_last_today,
|
||||
)
|
||||
|
||||
# Column width for a meal description in the status listing.
|
||||
_DESC_WIDTH = 24
|
||||
# An ISO timestamp formats as "YYYY-MM-DDTHH:MM:SS"; HH:MM is chars 11..16.
|
||||
_TIME_SLICE = slice(11, 16)
|
||||
# Accepted answers for the sex prompt that map to the male BMR constant.
|
||||
_MALE_ANSWERS = {"m", "male"}
|
||||
_FEMALE_ANSWERS = {"f", "female"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ManualMacros:
|
||||
"""User-supplied calories/macros for ``ate``, all optional.
|
||||
|
||||
Grouping these keeps :func:`_cmd_ate` within the argument-count limit and
|
||||
makes "manual values were supplied" a single, testable value object.
|
||||
|
||||
Attributes:
|
||||
kcal: Calories entered manually (None means look the food up instead).
|
||||
protein: Protein grams, recorded alongside ``kcal``.
|
||||
carbs: Carbohydrate grams, recorded alongside ``kcal``.
|
||||
fat: Fat grams, recorded alongside ``kcal``.
|
||||
"""
|
||||
|
||||
kcal: float | None
|
||||
protein: float | None
|
||||
carbs: float | None
|
||||
fat: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Portion:
|
||||
"""How much was eaten and the basis for any typed macros.
|
||||
|
||||
Grouped so :func:`_cmd_ate` stays within the argument-count limit.
|
||||
|
||||
Attributes:
|
||||
grams: Explicit grams eaten, or None.
|
||||
count: Number of items eaten (an alternative to ``grams``), or None.
|
||||
per_grams: Reference weight the typed macros are stated for (e.g. 100
|
||||
for a per-100 g label), or None to treat the macros as totals.
|
||||
"""
|
||||
|
||||
grams: float | None
|
||||
count: float | None
|
||||
per_grams: float | None
|
||||
|
||||
|
||||
def _emit(text: str = "") -> None:
|
||||
"""Write one line to stdout.
|
||||
|
||||
A thin wrapper over ``sys.stdout.write`` so genuine CLI output does not
|
||||
trip ruff's ``T201`` (no ``print``) without resorting to a suppression.
|
||||
"""
|
||||
sys.stdout.write(f"{text}\n")
|
||||
|
||||
|
||||
def _ask(label: str) -> str:
|
||||
"""Print a prompt label and return one trimmed line from stdin."""
|
||||
_emit(label)
|
||||
return sys.stdin.readline().strip()
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse diet_guard CLI arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="diet_guard",
|
||||
description="Log calories and check your daily budget.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser(
|
||||
"init",
|
||||
help="Compute your daily budget from biometrics and seal it (hidden).",
|
||||
)
|
||||
|
||||
ate = sub.add_parser("ate", help="Log a meal you just ate.")
|
||||
ate.add_argument("description", help='What you ate, e.g. "big mac".')
|
||||
ate.add_argument(
|
||||
"--grams",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Portion size in grams (default: OFF serving size, else 100 g).",
|
||||
)
|
||||
ate.add_argument(
|
||||
"--kcal",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Calories entered manually; skips the food bank and OFF lookup.",
|
||||
)
|
||||
ate.add_argument(
|
||||
"--protein",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Protein in grams (recorded with --kcal to seed the food bank).",
|
||||
)
|
||||
ate.add_argument(
|
||||
"--carbs",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Carbohydrate in grams (recorded with --kcal).",
|
||||
)
|
||||
ate.add_argument(
|
||||
"--fat",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Fat in grams (recorded with --kcal).",
|
||||
)
|
||||
ate.add_argument(
|
||||
"--per",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Grams the macros are stated for (e.g. 100 for a per-100 g label);"
|
||||
" the typed macros are scaled from this to how much you ate.",
|
||||
)
|
||||
ate.add_argument(
|
||||
"--count",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Number of items eaten (e.g. 5 apples) instead of --grams;"
|
||||
" multiplied by the staple's unit weight.",
|
||||
)
|
||||
|
||||
sub.add_parser("status", help="Show today's calories and budget band.")
|
||||
sub.add_parser("undo", help="Remove today's most recent entry.")
|
||||
|
||||
gate = sub.add_parser(
|
||||
"gate",
|
||||
help="Log-to-unlock screen gate (intended to be run by a timer).",
|
||||
)
|
||||
gate.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Headless: exit 0 if NOT due, 1 if a lock is due. Prints, no window.",
|
||||
)
|
||||
gate.add_argument(
|
||||
"--demo",
|
||||
action="store_true",
|
||||
help="Show the lock in safe demo mode (local grab + close button).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _print_summary() -> None:
|
||||
"""Print today's total and how much of the daily budget is left.
|
||||
|
||||
The budget number is shown here on purpose: it is "hidden" only in the
|
||||
sense of never leaving this machine (it lives outside the repo), not hidden
|
||||
from the user, who needs it to make portion decisions.
|
||||
"""
|
||||
total = today_total_kcal()
|
||||
try:
|
||||
budget = daily_budget()
|
||||
except BudgetNotInitializedError:
|
||||
_emit(
|
||||
f"today: {total:g} kcal "
|
||||
"(budget not set - run: python -m python_pkg.diet_guard init)",
|
||||
)
|
||||
return
|
||||
except BudgetSealBrokenError:
|
||||
_emit(f"today: {total:g} kcal (budget seal broken - re-run init)")
|
||||
return
|
||||
remaining = round(budget - total, 1)
|
||||
_emit(f"today: {total:g} kcal - {remaining:g} kcal left of {budget:g}")
|
||||
|
||||
|
||||
def _print_entry_line(entry: dict[str, object]) -> None:
|
||||
"""Print a single log entry as 'HH:MM desc kcal (source)'."""
|
||||
time_str = str(entry.get("time", ""))[_TIME_SLICE]
|
||||
desc = str(entry.get("desc", "?"))
|
||||
source = str(entry.get("source", ""))
|
||||
_emit(
|
||||
f" {time_str:>5} {desc:<{_DESC_WIDTH}.{_DESC_WIDTH}} "
|
||||
f"{entry_kcal(entry):>6.0f} kcal ({source})",
|
||||
)
|
||||
|
||||
|
||||
def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
|
||||
"""Prompt for biometrics on stdin; return (bio, activity, deficit) or None.
|
||||
|
||||
Returns None (after printing why) on any unparsable or out-of-range input,
|
||||
so a typo never seals a wrong budget.
|
||||
"""
|
||||
try:
|
||||
weight = float(_ask("weight in kg:"))
|
||||
height = float(_ask("height in cm:"))
|
||||
age = float(_ask("age in years:"))
|
||||
sex_raw = _ask("sex (m/f):").lower()
|
||||
activity = float(
|
||||
_ask(
|
||||
"activity factor "
|
||||
"(1.2 sedentary / 1.375 light / 1.55 moderate / 1.725 active):",
|
||||
),
|
||||
)
|
||||
deficit = float(_ask("daily deficit in kcal (e.g. 200):"))
|
||||
except ValueError:
|
||||
_emit("that was not a number; nothing was sealed.")
|
||||
return None
|
||||
|
||||
if sex_raw in _MALE_ANSWERS:
|
||||
is_male = True
|
||||
elif sex_raw in _FEMALE_ANSWERS:
|
||||
is_male = False
|
||||
else:
|
||||
_emit('sex must be "m" or "f"; nothing was sealed.')
|
||||
return None
|
||||
|
||||
bio = Biometrics(
|
||||
weight_kg=weight,
|
||||
height_cm=height,
|
||||
age_years=age,
|
||||
is_male=is_male,
|
||||
)
|
||||
return bio, activity, deficit
|
||||
|
||||
|
||||
def _cmd_init() -> int:
|
||||
"""Compute the budget from biometrics and seal it, printing no number."""
|
||||
inputs = _read_init_inputs()
|
||||
if inputs is None:
|
||||
return 2
|
||||
bio, activity, deficit = inputs
|
||||
budget = compute_target_budget(
|
||||
bio,
|
||||
activity_factor=activity,
|
||||
deficit_kcal=deficit,
|
||||
)
|
||||
try:
|
||||
seal_budget(budget, weight_kg=bio.weight_kg)
|
||||
except BudgetLockedError:
|
||||
_emit("the budget is locked; unlock it first, then re-run init:")
|
||||
_emit(f" {unlock_command()}")
|
||||
return 1
|
||||
_emit("budget computed from your biometrics and sealed - the number is")
|
||||
_emit("intentionally not shown.")
|
||||
_emit(f"to lock it against casual edits, run: {lock_command()}")
|
||||
return 0
|
||||
|
||||
|
||||
def _eaten_grams(
|
||||
description: str,
|
||||
portion: _Portion,
|
||||
) -> tuple[float | None, str | None]:
|
||||
"""Resolve how many grams were eaten, plus a note if a weight was assumed.
|
||||
|
||||
A count of items is turned into grams via the staple's unit weight; an
|
||||
unknown item falls back to a default weight, with a note so the estimate is
|
||||
never silent.
|
||||
|
||||
Args:
|
||||
description: The food name (used to look up a per-item weight).
|
||||
portion: The user's portion inputs.
|
||||
|
||||
Returns:
|
||||
``(grams, note)`` where ``grams`` may be None (no portion given) and
|
||||
``note`` is a one-line caveat to print, or None.
|
||||
"""
|
||||
if portion.count is not None:
|
||||
unit = estimate_unit_grams(description)
|
||||
if unit is None:
|
||||
return (
|
||||
portion.count * DEFAULT_ITEM_GRAMS,
|
||||
f"(assumed {DEFAULT_ITEM_GRAMS:g} g per item; "
|
||||
"pass --grams to be exact)",
|
||||
)
|
||||
return portion.count * unit, None
|
||||
return portion.grams, None
|
||||
|
||||
|
||||
def _cmd_ate(description: str, portion: _Portion, macros: _ManualMacros) -> int:
|
||||
"""Resolve and log a meal, tag its slot, bank it, then print the total.
|
||||
|
||||
Resolution order is manual, then food bank, then the staple table, then
|
||||
Open Food Facts (see :func:`resolve_nutrition`). A per-item count or a
|
||||
per-reference macro basis is converted to the amount actually eaten first,
|
||||
and the food is remembered so next time it is served from local history.
|
||||
"""
|
||||
eaten, note = _eaten_grams(description, portion)
|
||||
if note is not None:
|
||||
_emit(note)
|
||||
manual_macros = (
|
||||
ManualMacros(
|
||||
kcal=macros.kcal,
|
||||
protein=macros.protein or 0.0,
|
||||
carbs=macros.carbs or 0.0,
|
||||
fat=macros.fat or 0.0,
|
||||
per_grams=portion.per_grams,
|
||||
)
|
||||
if macros.kcal is not None
|
||||
else None
|
||||
)
|
||||
nutrition = resolve_nutrition(
|
||||
description,
|
||||
grams=eaten,
|
||||
manual_macros=manual_macros,
|
||||
)
|
||||
if nutrition is None:
|
||||
_emit(
|
||||
f'no food bank, staple, or Open Food Facts match for "{description}". '
|
||||
"re-run with --kcal <number> to log it manually.",
|
||||
)
|
||||
return 1
|
||||
log_meal(description, nutrition, current_slot(now_local()))
|
||||
remember_food(description, nutrition)
|
||||
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
|
||||
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"
|
||||
_emit(
|
||||
f"logged: {description} {nutrition.kcal:g} kcal "
|
||||
f"({macro_str}) [{nutrition.source}, {portion_str}]",
|
||||
)
|
||||
_print_summary()
|
||||
return 0
|
||||
|
||||
|
||||
def _print_slot_status() -> None:
|
||||
"""Print each meal slot as logged / DUE / upcoming for today."""
|
||||
logged = logged_slots_today()
|
||||
due = set(due_slots())
|
||||
parts: list[str] = []
|
||||
for slot in day_slots():
|
||||
if slot in logged:
|
||||
mark = "logged"
|
||||
elif slot in due:
|
||||
mark = "DUE"
|
||||
else:
|
||||
mark = "upcoming"
|
||||
parts.append(f"{slot_label(slot)} {mark}")
|
||||
_emit("slots: " + " ".join(parts))
|
||||
|
||||
|
||||
def _print_macro_status() -> None:
|
||||
"""Print today's macros so far, with the protein target when it is known.
|
||||
|
||||
Mirrors the gate's dashboard on the command line so "how am I doing" is
|
||||
answerable without opening the window. The protein target only appears once
|
||||
the budget has been initialized with a body weight (see ``init``).
|
||||
"""
|
||||
protein, carbs, fat = today_total_macros()
|
||||
line = f"macros: P{protein:g} C{carbs:g} F{fat:g} g"
|
||||
target = protein_target_g()
|
||||
if target is not None:
|
||||
remaining = round(target - protein, 1)
|
||||
line += f" - protein {protein:g}/{target:g} g ({remaining:g} left)"
|
||||
_emit(line)
|
||||
|
||||
|
||||
def _cmd_status() -> int:
|
||||
"""Print today's entries, per-slot status, macros, and the budget remaining."""
|
||||
entries = today_entries()
|
||||
for entry in entries:
|
||||
_print_entry_line(entry)
|
||||
if entries:
|
||||
_emit("-" * 48)
|
||||
_print_slot_status()
|
||||
_print_summary()
|
||||
_print_macro_status()
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_undo() -> int:
|
||||
"""Remove today's most recent entry and report what was removed."""
|
||||
removed = undo_last_today()
|
||||
if removed is None:
|
||||
_emit("nothing to undo today.")
|
||||
return 0
|
||||
desc = str(removed.get("desc", "?"))
|
||||
_emit(f"removed: {desc} ({entry_kcal(removed):g} kcal)")
|
||||
_print_summary()
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_gate(*, check: bool, demo: bool) -> int:
|
||||
"""Run the log-to-unlock gate.
|
||||
|
||||
Three modes: ``--check`` is a headless decision (no window) whose exit code
|
||||
a timer reads; ``--demo`` always shows a safe demo window; bare ``gate``
|
||||
shows the real lock only when one is due. A flock guard stops a second
|
||||
window from stacking on top of the first, and a window-opening mode first
|
||||
waits for the X display so a session-start launch never crashes unshown.
|
||||
|
||||
Args:
|
||||
check: Headless mode -- print and return an exit code, open no window.
|
||||
demo: Use safe demo mode (local grab + close button) for the window.
|
||||
|
||||
Returns:
|
||||
For ``--check``: 0 if not due, 1 if a lock is due. Otherwise 0.
|
||||
"""
|
||||
if check:
|
||||
due = gate_is_due()
|
||||
_emit("due (a lock is warranted)" if due else "ok (no lock needed)")
|
||||
return 1 if due else 0
|
||||
if not demo and not gate_is_due():
|
||||
_emit("ok - no lock needed right now.")
|
||||
return 0
|
||||
handle = acquire_gate_lock()
|
||||
if handle is None:
|
||||
_emit("the gate is already running.")
|
||||
return 0
|
||||
try:
|
||||
# At session start the timer can fire before the X display/auth cookie
|
||||
# is ready; wait it out so the window opens instead of crashing on a
|
||||
# "couldn't connect to display" TclError (see _gatelock.wait_for_display).
|
||||
if not wait_for_display():
|
||||
_emit("display not ready yet; will retry on the next timer tick.")
|
||||
return 0
|
||||
MealGate(demo_mode=demo).run()
|
||||
finally:
|
||||
release_gate_lock(handle)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Dispatch a diet_guard subcommand.
|
||||
|
||||
Args:
|
||||
argv: Argument list (defaults to ``sys.argv[1:]``).
|
||||
|
||||
Returns:
|
||||
A process exit code (0 on success).
|
||||
"""
|
||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
||||
if args.command == "init":
|
||||
return _cmd_init()
|
||||
if args.command == "ate":
|
||||
macros = _ManualMacros(
|
||||
kcal=args.kcal,
|
||||
protein=args.protein,
|
||||
carbs=args.carbs,
|
||||
fat=args.fat,
|
||||
)
|
||||
portion = _Portion(
|
||||
grams=args.grams,
|
||||
count=args.count,
|
||||
per_grams=args.per,
|
||||
)
|
||||
return _cmd_ate(args.description, portion, macros)
|
||||
if args.command == "status":
|
||||
return _cmd_status()
|
||||
if args.command == "gate":
|
||||
return _cmd_gate(check=args.check, demo=args.demo)
|
||||
return _cmd_undo()
|
||||
65
diet_guard/_constants.py
Normal file
65
diet_guard/_constants.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""Constants for the diet_guard calorie tracker and gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# --- Daily target -----------------------------------------------------------
|
||||
# There is deliberately NO budget number here. Like the home GPS coordinates in
|
||||
# phone_focus_mode (which live only in the git-ignored config_secrets.sh on the
|
||||
# device, never in committed source), the real budget is computed once from
|
||||
# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via
|
||||
# python_pkg.diet_guard._budget.daily_budget() for over/under decisions only and
|
||||
# is never printed -- see _budget.py for the full threat model.
|
||||
#
|
||||
# Fraction of the budget at which status flips from "on track" to "approaching
|
||||
# limit". Surfaced as a label, so the threshold leaks only by boundary-probing.
|
||||
BUDGET_WARN_FRACTION: float = 0.80
|
||||
|
||||
# --- Storage ----------------------------------------------------------------
|
||||
# The food log is personal and high-churn, so it lives in the XDG data dir and
|
||||
# is deliberately NOT committed to git (unlike wake_state.json).
|
||||
DATA_DIR: Path = Path.home() / ".local" / "share" / "diet_guard"
|
||||
FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json"
|
||||
# The user's personal "food bank": every food they have logged before, with its
|
||||
# full macros, keyed by name. This is the ONLY corpus the gate's autocomplete
|
||||
# searches -- Open Food Facts is used to *fill* a new food's macros, never to
|
||||
# search. Local-only, git-ignored.
|
||||
FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json"
|
||||
# The sealed budget: a dotfile alongside the log, base64-wrapped + HMAC-signed,
|
||||
# made immutable with ``chattr +i``. Git-ignored, never committed. "Hidden"
|
||||
# here means never-online (it lives outside the repo) -- the number is still
|
||||
# shown freely in local CLI/GUI output; the seal only makes *cheating* hard.
|
||||
BUDGET_FILE: Path = DATA_DIR / ".budget"
|
||||
|
||||
# --- Estimator (Open Food Facts) -------------------------------------------
|
||||
# The default backend is Open Food Facts' "Search-a-licious" full-text search:
|
||||
# free, no key, strongest for branded/packaged foods (including fast food).
|
||||
# (The older cgi/search.pl endpoint is heavily rate-limited and returns an HTML
|
||||
# "temporarily unavailable" page to API clients, and /api/v2/search ignores the
|
||||
# query term, so neither is usable here.) Swappable for a local/remote LLM
|
||||
# backend later without touching the log or CLI layers.
|
||||
OFF_SEARCH_URL: str = "https://search.openfoodfacts.org/search"
|
||||
OFF_TIMEOUT_SECONDS: float = 8.0
|
||||
OFF_PAGE_SIZE: int = 5
|
||||
# Open Food Facts asks API clients to identify themselves with a descriptive
|
||||
# User-Agent string so abusive clients can be told apart from polite ones.
|
||||
OFF_USER_AGENT: str = "diet_guard/1.0 (personal diet tracker)"
|
||||
# Portion assumed when neither --grams nor an OFF serving size is available.
|
||||
DEFAULT_PORTION_GRAMS: float = 100.0
|
||||
|
||||
# --- Gate (log-to-unlock) ---------------------------------------------------
|
||||
# The gate is driven by FIXED MEAL SLOTS, not by a gap timer. Starting at the
|
||||
# day-start hour, a slot opens every interval; once a slot's hour has passed,
|
||||
# that slot must carry a logged meal or the screen locks until it does. This
|
||||
# makes tracking fully automatic (you are prompted on a schedule rather than
|
||||
# trusted to log voluntarily) and nudges regular eating. Coming home late
|
||||
# naturally produces several unlogged elapsed slots at once -> one lock that
|
||||
# backfills the whole day, which is the "requirement to access the PC" behavior.
|
||||
GATE_DAY_START_HOUR: int = 8 # first slot (08:00); also the "beginning of day"
|
||||
GATE_SLOT_INTERVAL_HOURS: int = 4 # slots at 08:00, 12:00, 16:00, 20:00
|
||||
# Past this hour the gate never fires, so an unlogged late slot lapses quietly
|
||||
# instead of locking you out overnight. (A new day resets all slots at 00:00.)
|
||||
GATE_EATING_END_HOUR: int = 22 # exclusive (22:00)
|
||||
# flock single-instance guard: stops a timer from stacking lock windows.
|
||||
GATE_LOCK_FILE: Path = DATA_DIR / ".gate.lock"
|
||||
325
diet_guard/_estimator.py
Normal file
325
diet_guard/_estimator.py
Normal file
@ -0,0 +1,325 @@
|
||||
"""Calorie/macro estimation backends for diet_guard.
|
||||
|
||||
The default backend queries the public Open Food Facts (OFF) database over
|
||||
HTTP -- no API key required. It is strongest for branded/packaged foods
|
||||
(fast food included, which is the binge target) and weaker for generic
|
||||
home-cooked descriptions; in the latter case the caller should fall back to a
|
||||
manual ``--kcal`` value.
|
||||
|
||||
The backend is intentionally small and pluggable: replace :func:`estimate`
|
||||
with a local-LLM (ollama) or remote-LLM implementation later without touching
|
||||
the log/state or CLI layers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from python_pkg.diet_guard._constants import (
|
||||
DEFAULT_PORTION_GRAMS,
|
||||
OFF_PAGE_SIZE,
|
||||
OFF_SEARCH_URL,
|
||||
OFF_TIMEOUT_SECONDS,
|
||||
OFF_USER_AGENT,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Open Food Facts nutriment field names (values are "per 100 g").
|
||||
_OFF_KCAL_FIELD = "energy-kcal_100g"
|
||||
_OFF_PROTEIN_FIELD = "proteins_100g"
|
||||
_OFF_CARBS_FIELD = "carbohydrates_100g"
|
||||
_OFF_FAT_FIELD = "fat_100g"
|
||||
_GRAMS_PER_REFERENCE = 100.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Nutrition:
|
||||
"""Estimated nutrition for one logged portion of food.
|
||||
|
||||
Attributes:
|
||||
kcal: Total energy for the portion, in kilocalories.
|
||||
protein_g: Protein for the portion, in grams.
|
||||
carbs_g: Carbohydrate for the portion, in grams.
|
||||
fat_g: Fat for the portion, in grams.
|
||||
grams: Portion size used for the estimate, in grams (0 if unknown).
|
||||
source: Human-readable provenance, e.g. ``"openfoodfacts: Big Mac"``
|
||||
or ``"manual"``.
|
||||
"""
|
||||
|
||||
kcal: float
|
||||
protein_g: float
|
||||
carbs_g: float
|
||||
fat_g: float
|
||||
grams: float
|
||||
source: str
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
"""Coerce an Open Food Facts numeric field to ``float``.
|
||||
|
||||
OFF returns numbers as ints, floats, or numeric strings depending on the
|
||||
product, so accept all three. ``bool`` is rejected even though it is an
|
||||
``int`` subtype, since a boolean nutriment value is meaningless.
|
||||
|
||||
Args:
|
||||
value: The raw field value.
|
||||
|
||||
Returns:
|
||||
The value as a float, or None if it is not numeric.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def manual(
|
||||
kcal: float,
|
||||
grams: float | None = None,
|
||||
*,
|
||||
protein_g: float = 0.0,
|
||||
carbs_g: float = 0.0,
|
||||
fat_g: float = 0.0,
|
||||
) -> Nutrition:
|
||||
"""Build a :class:`Nutrition` from user-supplied values.
|
||||
|
||||
Calories are required; the three macros are optional so the offline path
|
||||
stays low-friction (a bare ``--kcal`` always works) while a user who knows
|
||||
the full breakdown can record it and seed the food bank with it.
|
||||
|
||||
Args:
|
||||
kcal: Calories the user entered directly.
|
||||
grams: Optional portion size, kept only for display.
|
||||
protein_g: Protein in grams (0 if unknown).
|
||||
carbs_g: Carbohydrate in grams (0 if unknown).
|
||||
fat_g: Fat in grams (0 if unknown).
|
||||
|
||||
Returns:
|
||||
A Nutrition with the supplied macros and ``source="manual"``.
|
||||
"""
|
||||
return Nutrition(
|
||||
kcal=round(float(kcal), 1),
|
||||
protein_g=round(float(protein_g), 1),
|
||||
carbs_g=round(float(carbs_g), 1),
|
||||
fat_g=round(float(fat_g), 1),
|
||||
grams=round(float(grams), 1) if grams is not None else 0.0,
|
||||
source="manual",
|
||||
)
|
||||
|
||||
|
||||
def scale_nutrition(nutrition: Nutrition, grams: float) -> Nutrition:
|
||||
"""Rescale a portion's macros to a new weight in grams (pure).
|
||||
|
||||
A banked or looked-up food stores the macros for *some* portion; eating a
|
||||
different amount must scale every macro proportionally, so 200 g of a food
|
||||
banked at 100 g logs double the calories. When the basis portion is unknown
|
||||
(``grams == 0``) there is nothing to scale from, so the macros are kept and
|
||||
only the recorded weight is updated -- best effort rather than a wrong
|
||||
number.
|
||||
|
||||
Args:
|
||||
nutrition: The basis nutrition (its ``grams`` is the basis weight).
|
||||
grams: The new portion weight in grams.
|
||||
|
||||
Returns:
|
||||
A new Nutrition scaled to ``grams`` (source preserved).
|
||||
"""
|
||||
if nutrition.grams <= 0 or grams <= 0:
|
||||
return replace(nutrition, grams=grams if grams > 0 else nutrition.grams)
|
||||
factor = grams / nutrition.grams
|
||||
return replace(
|
||||
nutrition,
|
||||
kcal=round(nutrition.kcal * factor, 1),
|
||||
protein_g=round(nutrition.protein_g * factor, 1),
|
||||
carbs_g=round(nutrition.carbs_g * factor, 1),
|
||||
fat_g=round(nutrition.fat_g * factor, 1),
|
||||
grams=round(grams, 1),
|
||||
)
|
||||
|
||||
|
||||
def _off_search(term: str) -> list[dict[str, object]]:
|
||||
"""Query Open Food Facts for products matching ``term``.
|
||||
|
||||
Args:
|
||||
term: Free-text food description.
|
||||
|
||||
Returns:
|
||||
A list of product dicts (possibly empty), most relevant first.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: On any network or HTTP failure.
|
||||
"""
|
||||
params = {
|
||||
"q": term,
|
||||
"fields": "product_name,nutriments,serving_quantity",
|
||||
"page_size": str(OFF_PAGE_SIZE),
|
||||
}
|
||||
response = requests.get(
|
||||
OFF_SEARCH_URL,
|
||||
params=params,
|
||||
headers={"User-Agent": OFF_USER_AGENT},
|
||||
timeout=OFF_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
# Search-a-licious returns matches under "hits" (ranked by relevance).
|
||||
hits = payload.get("hits", [])
|
||||
if not isinstance(hits, list):
|
||||
return []
|
||||
return [hit for hit in hits if isinstance(hit, dict)]
|
||||
|
||||
|
||||
def _products_with_energy(
|
||||
products: list[dict[str, object]],
|
||||
) -> list[tuple[dict[str, object], dict[str, object]]]:
|
||||
"""Return the products that carry a usable kcal/100 g value, in order.
|
||||
|
||||
Args:
|
||||
products: Product dicts from :func:`_off_search`.
|
||||
|
||||
Returns:
|
||||
``(product, nutriments)`` tuples for every product with a kcal value,
|
||||
preserving Open Food Facts' relevance order.
|
||||
"""
|
||||
matches: list[tuple[dict[str, object], dict[str, object]]] = []
|
||||
for product in products:
|
||||
nutriments = product.get("nutriments")
|
||||
if not isinstance(nutriments, dict):
|
||||
continue
|
||||
if _as_float(nutriments.get(_OFF_KCAL_FIELD)) is not None:
|
||||
matches.append((product, nutriments))
|
||||
return matches
|
||||
|
||||
|
||||
def _resolve_portion(grams: float | None, product: dict[str, object]) -> float:
|
||||
"""Decide the portion size, in grams, to use for an estimate.
|
||||
|
||||
Priority: an explicit ``grams`` argument, then the product's Open Food
|
||||
Facts serving size, then the configured default. Keeping ``--grams``
|
||||
optional is deliberate: per-entry friction is the whole reason food diaries
|
||||
get abandoned, so ``ate "big mac"`` must just work.
|
||||
|
||||
Args:
|
||||
grams: Caller-supplied portion, or None.
|
||||
product: OFF product dict (may carry ``serving_quantity``).
|
||||
|
||||
Returns:
|
||||
A portion size in grams, always greater than zero.
|
||||
"""
|
||||
if grams is not None and grams > 0:
|
||||
return float(grams)
|
||||
serving = _as_float(product.get("serving_quantity"))
|
||||
if serving is not None and serving > 0:
|
||||
return serving
|
||||
return DEFAULT_PORTION_GRAMS
|
||||
|
||||
|
||||
def _off_nutrition(
|
||||
product: dict[str, object],
|
||||
nutriments: dict[str, object],
|
||||
grams: float | None,
|
||||
description: str,
|
||||
) -> Nutrition:
|
||||
"""Build a Nutrition for one OFF product, scaled to the chosen portion."""
|
||||
portion = _resolve_portion(grams, product)
|
||||
factor = portion / _GRAMS_PER_REFERENCE
|
||||
name = product.get("product_name")
|
||||
label = name if isinstance(name, str) and name.strip() else description
|
||||
return Nutrition(
|
||||
kcal=round(_scaled(nutriments, _OFF_KCAL_FIELD, factor), 1),
|
||||
protein_g=round(_scaled(nutriments, _OFF_PROTEIN_FIELD, factor), 1),
|
||||
carbs_g=round(_scaled(nutriments, _OFF_CARBS_FIELD, factor), 1),
|
||||
fat_g=round(_scaled(nutriments, _OFF_FAT_FIELD, factor), 1),
|
||||
grams=round(portion, 1),
|
||||
source=f"openfoodfacts: {label}",
|
||||
)
|
||||
|
||||
|
||||
def off_candidates(
|
||||
description: str,
|
||||
grams: float | None = None,
|
||||
limit: int = OFF_PAGE_SIZE,
|
||||
) -> list[Nutrition]:
|
||||
"""Return up to ``limit`` Open Food Facts matches for ``description``.
|
||||
|
||||
Returning several candidates (rather than only the top hit) lets the gate
|
||||
show alternatives so the user can pick the product that actually matches
|
||||
what they ate, instead of silently accepting the first guess.
|
||||
|
||||
Args:
|
||||
description: Free-text food description (e.g. ``"big mac"``).
|
||||
grams: Portion size in grams; serving size or the default is used when
|
||||
None.
|
||||
limit: Maximum number of candidates to return.
|
||||
|
||||
Returns:
|
||||
Nutrition estimates in OFF relevance order (empty if OFF is unreachable
|
||||
or has no usable match).
|
||||
"""
|
||||
try:
|
||||
products = _off_search(description)
|
||||
except requests.RequestException as exc:
|
||||
_logger.warning("Open Food Facts request failed: %s", exc)
|
||||
return []
|
||||
return [
|
||||
_off_nutrition(product, nutriments, grams, description)
|
||||
for product, nutriments in _products_with_energy(products)[:limit]
|
||||
]
|
||||
|
||||
|
||||
def estimate_off(description: str, grams: float | None) -> Nutrition | None:
|
||||
"""Estimate nutrition for ``description`` via Open Food Facts (top match).
|
||||
|
||||
Args:
|
||||
description: Free-text food description (e.g. ``"big mac"``).
|
||||
grams: Portion size in grams. When None, the product's serving size
|
||||
is used if known, otherwise the configured default portion.
|
||||
|
||||
Returns:
|
||||
The best Nutrition estimate, or None if OFF is unreachable or has no
|
||||
usable match (the caller should then fall back to a manual value).
|
||||
"""
|
||||
candidates = off_candidates(description, grams, limit=1)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def _scaled(nutriments: dict[str, object], field: str, factor: float) -> float:
|
||||
"""Return a per-100 g nutriment scaled to the portion (0 if missing)."""
|
||||
per_reference = _as_float(nutriments.get(field))
|
||||
if per_reference is None:
|
||||
return 0.0
|
||||
return per_reference * factor
|
||||
|
||||
|
||||
def estimate(
|
||||
description: str,
|
||||
*,
|
||||
grams: float | None = None,
|
||||
manual_kcal: float | None = None,
|
||||
) -> Nutrition | None:
|
||||
"""Estimate nutrition for a meal; a manual value takes precedence.
|
||||
|
||||
Args:
|
||||
description: Free-text food description.
|
||||
grams: Optional portion size in grams.
|
||||
manual_kcal: If given, used directly and Open Food Facts is skipped.
|
||||
|
||||
Returns:
|
||||
A Nutrition estimate, or None when no manual value was supplied and OFF
|
||||
could not produce a usable match.
|
||||
"""
|
||||
if manual_kcal is not None:
|
||||
return manual(manual_kcal, grams)
|
||||
return estimate_off(description, grams)
|
||||
281
diet_guard/_foodbank.py
Normal file
281
diet_guard/_foodbank.py
Normal file
@ -0,0 +1,281 @@
|
||||
"""The user's personal food bank: a local corpus of previously logged foods.
|
||||
|
||||
Every food the user logs is remembered here with its full macros, keyed by a
|
||||
normalized name. The gate's autocomplete searches *only* this corpus -- never
|
||||
Open Food Facts. OFF (in :mod:`python_pkg.diet_guard._estimator`) is used only
|
||||
to *fill in* the macros of a brand-new food the first time it is entered; from
|
||||
then on the food is served from the bank, so search quality improves with use
|
||||
and works fully offline.
|
||||
|
||||
Search is intentionally typo-tolerant. Rather than a prefix/exact match, it
|
||||
combines substring containment with :func:`difflib.SequenceMatcher` similarity
|
||||
(stdlib -- no extra dependency), so "chiken breast" still finds "chicken
|
||||
breast". Results are ranked by match quality, then by how often the food has
|
||||
been logged, so your staples float to the top.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.diet_guard._constants import FOOD_BANK_FILE
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._fuzzy import match_score
|
||||
from python_pkg.diet_guard._meal import MealItem, meal_total
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Below this similarity ratio a non-substring candidate is not a plausible typo
|
||||
# of the query and is dropped. SequenceMatcher's own "close match" default is
|
||||
# 0.6; we reuse it so behavior matches difflib intuitions.
|
||||
_FUZZY_THRESHOLD = 0.6
|
||||
# Default number of autocomplete suggestions to surface.
|
||||
DEFAULT_SUGGESTIONS = 8
|
||||
|
||||
# On-disk shape: {normalized_name: {"desc", "kcal", "protein_g", "carbs_g",
|
||||
# "fat_g", "grams", "count"}}. ``count`` ranks frequently eaten staples first.
|
||||
BankRecord = dict[str, object]
|
||||
|
||||
|
||||
def _normalize(description: str) -> str:
|
||||
"""Return the lookup key for a description (trimmed, case-folded)."""
|
||||
return description.strip().casefold()
|
||||
|
||||
|
||||
def _read_bank() -> dict[str, BankRecord]:
|
||||
"""Read the food bank from disk (empty dict on any error).
|
||||
|
||||
A corrupt or unreadable file is moved aside (see
|
||||
:func:`_quarantine_corrupt_bank`) rather than re-warned about on every call:
|
||||
the gate reads the bank on each keystroke, so a single bad file would
|
||||
otherwise flood the journal and then be silently overwritten by the next
|
||||
write.
|
||||
"""
|
||||
if not FOOD_BANK_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
with FOOD_BANK_FILE.open() as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_quarantine_corrupt_bank()
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if isinstance(key, str) and isinstance(value, dict)
|
||||
}
|
||||
|
||||
|
||||
def _quarantine_corrupt_bank() -> None:
|
||||
"""Move an unreadable bank aside to a timestamped backup, warning once.
|
||||
|
||||
Renaming the bad file means the next read finds nothing and returns an empty
|
||||
bank quietly (no per-keystroke warning flood), the next write starts a fresh
|
||||
bank, and the original is preserved for manual recovery instead of being
|
||||
silently overwritten and lost.
|
||||
"""
|
||||
backup = FOOD_BANK_FILE.with_name(
|
||||
f"{FOOD_BANK_FILE.name}.corrupt-{int(time.time())}",
|
||||
)
|
||||
try:
|
||||
FOOD_BANK_FILE.rename(backup)
|
||||
except OSError:
|
||||
_logger.warning(
|
||||
"Food bank %s is unreadable and cannot be moved", FOOD_BANK_FILE
|
||||
)
|
||||
return
|
||||
_logger.warning(
|
||||
"Food bank %s was unreadable; moved aside to %s and starting fresh",
|
||||
FOOD_BANK_FILE,
|
||||
backup,
|
||||
)
|
||||
|
||||
|
||||
def _write_bank(bank: dict[str, BankRecord]) -> None:
|
||||
"""Persist the food bank to disk, creating the data directory if needed."""
|
||||
FOOD_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FOOD_BANK_FILE.open("w") as handle:
|
||||
json.dump(bank, handle, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def _record_to_nutrition(record: BankRecord) -> Nutrition:
|
||||
"""Build a :class:`Nutrition` from a stored bank record.
|
||||
|
||||
Missing or non-numeric fields default to 0.0 so a hand-edited or partial
|
||||
record can never raise while the user is mid-log.
|
||||
|
||||
Args:
|
||||
record: A stored food-bank record.
|
||||
|
||||
Returns:
|
||||
The reconstructed Nutrition (source marked as the food bank).
|
||||
"""
|
||||
return Nutrition(
|
||||
kcal=_as_float(record.get("kcal")),
|
||||
protein_g=_as_float(record.get("protein_g")),
|
||||
carbs_g=_as_float(record.get("carbs_g")),
|
||||
fat_g=_as_float(record.get("fat_g")),
|
||||
grams=_as_float(record.get("grams")),
|
||||
source="food bank",
|
||||
)
|
||||
|
||||
|
||||
def _as_float(value: object) -> float:
|
||||
"""Coerce a stored field to float, defaulting to 0.0 (bools rejected)."""
|
||||
if isinstance(value, bool):
|
||||
return 0.0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return 0.0
|
||||
|
||||
|
||||
def remember_food(description: str, nutrition: Nutrition) -> None:
|
||||
"""Record (or refresh) a food in the bank, bumping its use count.
|
||||
|
||||
The latest macros win, so correcting a food's calories once fixes every
|
||||
future suggestion. A blank description is ignored.
|
||||
|
||||
Args:
|
||||
description: The user's free-text food name.
|
||||
nutrition: The macros to store for it.
|
||||
"""
|
||||
_upsert(description, nutrition, components=None)
|
||||
|
||||
|
||||
def remember_meal(name: str, items: Sequence[MealItem]) -> Nutrition:
|
||||
"""Bank each component and the composite meal, returning the summed macros.
|
||||
|
||||
Each item is remembered on its own (so it autocompletes next time) and the
|
||||
meal is stored as one entry carrying its summed macros plus its component
|
||||
names, so the whole meal can be re-picked later as a single summed food. A
|
||||
blank meal name still banks the items but stores no empty-keyed composite.
|
||||
|
||||
Args:
|
||||
name: The composite meal's name (e.g. ``"dinner"``).
|
||||
items: The meal's components, each with its own nutrition.
|
||||
|
||||
Returns:
|
||||
The summed nutrition for the whole meal.
|
||||
"""
|
||||
for item in items:
|
||||
remember_food(item.name, item.nutrition)
|
||||
total = meal_total(items)
|
||||
_upsert(name, total, components=[item.name for item in items])
|
||||
return total
|
||||
|
||||
|
||||
def _upsert(
|
||||
description: str,
|
||||
nutrition: Nutrition,
|
||||
*,
|
||||
components: list[str] | None,
|
||||
) -> None:
|
||||
"""Insert or refresh one bank record, bumping its use count.
|
||||
|
||||
Shared by :func:`remember_food` (a single food) and :func:`remember_meal`
|
||||
(a composite, which additionally records its ``components``). A blank
|
||||
description is ignored, so an unnamed entry is never stored.
|
||||
|
||||
Args:
|
||||
description: The food or meal name (its normalized form is the key).
|
||||
nutrition: The macros to store.
|
||||
components: Component names for a composite meal, or None for a food.
|
||||
"""
|
||||
key = _normalize(description)
|
||||
if not key:
|
||||
return
|
||||
bank = _read_bank()
|
||||
previous = bank.get(key, {})
|
||||
count = _as_float(previous.get("count")) + 1
|
||||
record: BankRecord = {
|
||||
"desc": description.strip(),
|
||||
"kcal": nutrition.kcal,
|
||||
"protein_g": nutrition.protein_g,
|
||||
"carbs_g": nutrition.carbs_g,
|
||||
"fat_g": nutrition.fat_g,
|
||||
"grams": nutrition.grams,
|
||||
"count": count,
|
||||
}
|
||||
if components is not None:
|
||||
record["components"] = list(components)
|
||||
bank[key] = record
|
||||
_write_bank(bank)
|
||||
|
||||
|
||||
def lookup_food(description: str) -> Nutrition | None:
|
||||
"""Return the exact-match macros for ``description``, or None.
|
||||
|
||||
Args:
|
||||
description: The food name to look up verbatim (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The stored Nutrition, or None if the food is not banked.
|
||||
"""
|
||||
record = _read_bank().get(_normalize(description))
|
||||
return _record_to_nutrition(record) if record is not None else None
|
||||
|
||||
|
||||
def _display_name(record: BankRecord, key: str) -> str:
|
||||
"""Return a record's display name, falling back to its key."""
|
||||
desc = record.get("desc")
|
||||
return desc if isinstance(desc, str) and desc.strip() else key
|
||||
|
||||
|
||||
def search_foods(
|
||||
query: str,
|
||||
limit: int = DEFAULT_SUGGESTIONS,
|
||||
) -> list[tuple[str, Nutrition]]:
|
||||
"""Return banked foods matching ``query``, best match first.
|
||||
|
||||
An empty query returns the most-logged foods (the expandable full list).
|
||||
A non-empty query keeps substring and close-typo matches, ranked by match
|
||||
quality then by use count.
|
||||
|
||||
Args:
|
||||
query: Free-text the user has typed so far.
|
||||
limit: Maximum number of suggestions to return.
|
||||
|
||||
Returns:
|
||||
``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long.
|
||||
"""
|
||||
bank = _read_bank()
|
||||
normalized = _normalize(query)
|
||||
if not normalized:
|
||||
return _ranked_all(bank, limit)
|
||||
|
||||
scored: list[tuple[float, float, str, Nutrition]] = []
|
||||
for key, record in bank.items():
|
||||
score = match_score(normalized, key)
|
||||
if score < _FUZZY_THRESHOLD:
|
||||
continue
|
||||
count = _as_float(record.get("count"))
|
||||
scored.append(
|
||||
(score, count, _display_name(record, key), _record_to_nutrition(record)),
|
||||
)
|
||||
# Sort by score then frequency, both descending.
|
||||
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
return [(name, nutrition) for _, _, name, nutrition in scored[:limit]]
|
||||
|
||||
|
||||
def _ranked_all(
|
||||
bank: dict[str, BankRecord],
|
||||
limit: int,
|
||||
) -> list[tuple[str, Nutrition]]:
|
||||
"""Return all banked foods ranked by use count, most-logged first."""
|
||||
ranked = sorted(
|
||||
bank.items(),
|
||||
key=lambda item: _as_float(item[1].get("count")),
|
||||
reverse=True,
|
||||
)
|
||||
return [
|
||||
(_display_name(record, key), _record_to_nutrition(record))
|
||||
for key, record in ranked[:limit]
|
||||
]
|
||||
63
diet_guard/_fuzzy.py
Normal file
63
diet_guard/_fuzzy.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Shared typo-tolerant string matching for diet_guard.
|
||||
|
||||
Two callers need the same similarity logic: the food bank (matching what the
|
||||
user typed against foods they have logged) and the portions table (matching a
|
||||
description like "apple" against the known staples). Both depend on the same
|
||||
key property -- a short typo must still match a long multi-word name -- so the
|
||||
scoring lives here once rather than being copied.
|
||||
|
||||
The trick is to score *word by word* instead of whole-string to whole-string.
|
||||
"beast" scores near zero against "grilled chicken breast" as a whole (the
|
||||
length gap dominates) but ~0.91 against the single token "breast"; taking the
|
||||
best matching token per query word and averaging is what rescues the short
|
||||
typo. Built on :class:`difflib.SequenceMatcher` (stdlib, no dependency).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
def token_score(query: str, name: str) -> float:
|
||||
"""Score ``query`` against ``name`` word-by-word (length-penalty free).
|
||||
|
||||
Each query word is matched against its best word in ``name`` and the
|
||||
per-word similarities are averaged, so a short typo matches the relevant
|
||||
word in a long multi-word name instead of being drowned out by length.
|
||||
|
||||
Args:
|
||||
query: The normalized user query.
|
||||
name: The normalized candidate name.
|
||||
|
||||
Returns:
|
||||
The mean best-per-word similarity in ``[0, 1]``.
|
||||
"""
|
||||
query_words = query.split()
|
||||
name_words = name.split()
|
||||
if not query_words or not name_words:
|
||||
return SequenceMatcher(None, query, name).ratio()
|
||||
total = 0.0
|
||||
for word in query_words:
|
||||
total += max(
|
||||
SequenceMatcher(None, word, target).ratio() for target in name_words
|
||||
)
|
||||
return total / len(query_words)
|
||||
|
||||
|
||||
def match_score(query: str, name: str) -> float:
|
||||
"""Score how well ``name`` matches ``query`` (higher is better).
|
||||
|
||||
A substring hit scores at or above 1.0 (boosted by how much of the name the
|
||||
query covers, so the tightest containing name wins); otherwise fall back to
|
||||
the token-aware fuzzy score, which tolerates per-word typos.
|
||||
|
||||
Args:
|
||||
query: The normalized user query.
|
||||
name: The normalized candidate name.
|
||||
|
||||
Returns:
|
||||
A score; substring matches are ``>= 1.0``, fuzzy matches in ``[0, 1)``.
|
||||
"""
|
||||
if query and query in name:
|
||||
return 1.0 + len(query) / len(name)
|
||||
return token_score(query, name)
|
||||
67
diet_guard/_gate.py
Normal file
67
diet_guard/_gate.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Decision logic for the diet_guard slot-based log-to-unlock gate.
|
||||
|
||||
This module is GUI-free and side-effect-free so the lock/no-lock decision can
|
||||
be verified headlessly: the fullscreen window in ``_gatelock.py`` is only a
|
||||
thin shell around :func:`gate_is_due` and :func:`due_slots`. It composes the
|
||||
pure slot arithmetic in :mod:`python_pkg.diet_guard._slots` with the logged-slot
|
||||
state in :mod:`python_pkg.diet_guard._state`; ``now`` is injectable so the
|
||||
time-of-day rules stay deterministically testable.
|
||||
|
||||
The gate fires when any *elapsed* meal slot for today carries no logged meal.
|
||||
Coming home late therefore surfaces several unlogged slots at once -- a single
|
||||
lock that backfills the whole day before the PC is usable -- while a normal day
|
||||
prompts one slot at a time, with no separate weekday code path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.diet_guard._slots import missing_slots, slot_label
|
||||
from python_pkg.diet_guard._state import logged_slots_today, now_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def due_slots(now: datetime | None = None) -> tuple[int, ...]:
|
||||
"""Return today's elapsed-but-unlogged meal slots, ascending.
|
||||
|
||||
Args:
|
||||
now: Reference time (defaults to the current local time); injectable.
|
||||
|
||||
Returns:
|
||||
The slot hours that still need a meal logged (empty == nothing due).
|
||||
"""
|
||||
reference = now if now is not None else now_local()
|
||||
return missing_slots(reference, logged_slots_today())
|
||||
|
||||
|
||||
def gate_is_due(now: datetime | None = None) -> bool:
|
||||
"""Return True if the screen should lock until the missing slots are filled.
|
||||
|
||||
Args:
|
||||
now: Reference time (defaults to the current local time); injectable.
|
||||
|
||||
Returns:
|
||||
True if at least one elapsed slot today is unlogged, else False.
|
||||
"""
|
||||
return bool(due_slots(now))
|
||||
|
||||
|
||||
def gate_message(now: datetime | None = None) -> str:
|
||||
"""Return the lock-screen reason line listing the slots to backfill.
|
||||
|
||||
Args:
|
||||
now: Reference time (defaults to the current local time); injectable.
|
||||
|
||||
Returns:
|
||||
A short human-readable explanation of which meals are missing.
|
||||
"""
|
||||
missing = due_slots(now)
|
||||
if not missing:
|
||||
return "All meals are logged. You're up to date."
|
||||
labels = ", ".join(slot_label(slot) for slot in missing)
|
||||
if len(missing) == 1:
|
||||
return f"Log your {labels} meal to unlock."
|
||||
return f"Log your meals for {labels} to unlock."
|
||||
1334
diet_guard/_gatelock.py
Normal file
1334
diet_guard/_gatelock.py
Normal file
File diff suppressed because it is too large
Load Diff
65
diet_guard/_meal.py
Normal file
65
diet_guard/_meal.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""Composite "meal" support for diet_guard.
|
||||
|
||||
A meal is a named group of individually-macroed items -- e.g. a dinner of
|
||||
salad + chicken + rice, each entered with its own calories and macros. The
|
||||
meal's nutrition is the sum of its items. Both the individual items and the
|
||||
composite meal are saved to the food bank (see
|
||||
:func:`python_pkg.diet_guard._foodbank.remember_meal`), so next time each item
|
||||
autocompletes on its own and the whole meal can be picked as one summed entry.
|
||||
|
||||
This module is deliberately pure (no I/O): the sum is a total function of its
|
||||
items, which keeps the arithmetic exhaustively unit-testable apart from the
|
||||
bank persistence and the gate UI that compose it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
# Provenance stamped on a summed meal so the log/UI can tell a composite apart
|
||||
# from a single looked-up food.
|
||||
MEAL_SOURCE = "meal"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MealItem:
|
||||
"""One named component of a composite meal, with its own nutrition.
|
||||
|
||||
Attributes:
|
||||
name: The component's food name (e.g. ``"chicken"``).
|
||||
nutrition: The component's resolved macros for the amount eaten.
|
||||
"""
|
||||
|
||||
name: str
|
||||
nutrition: Nutrition
|
||||
|
||||
|
||||
def meal_total(items: Sequence[MealItem]) -> Nutrition:
|
||||
"""Return the summed nutrition of a meal's items.
|
||||
|
||||
Every macro and the portion weight are added across the items and rounded to
|
||||
0.1, and the result is stamped ``source=MEAL_SOURCE`` so it is
|
||||
distinguishable from a single food. An empty sequence sums to an all-zero
|
||||
meal rather than raising, so callers need not special-case "no items yet".
|
||||
|
||||
Args:
|
||||
items: The meal's components.
|
||||
|
||||
Returns:
|
||||
A :class:`~python_pkg.diet_guard._estimator.Nutrition` whose fields are
|
||||
the per-item sums.
|
||||
"""
|
||||
return Nutrition(
|
||||
kcal=round(sum((item.nutrition.kcal for item in items), 0.0), 1),
|
||||
protein_g=round(sum((item.nutrition.protein_g for item in items), 0.0), 1),
|
||||
carbs_g=round(sum((item.nutrition.carbs_g for item in items), 0.0), 1),
|
||||
fat_g=round(sum((item.nutrition.fat_g for item in items), 0.0), 1),
|
||||
grams=round(sum((item.nutrition.grams for item in items), 0.0), 1),
|
||||
source=MEAL_SOURCE,
|
||||
)
|
||||
171
diet_guard/_portions.py
Normal file
171
diet_guard/_portions.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""Built-in portion knowledge: unit weights and macros for common staples.
|
||||
|
||||
Two problems this solves, both seen in real use:
|
||||
|
||||
* **Counting by the piece.** People eat "5 apples", not "910 grams of apple".
|
||||
To turn a count into grams the program needs to know what one piece weighs.
|
||||
* **Open Food Facts is wrong for bare generics.** Searching OFF for "apple"
|
||||
returns a packaged apple *pastry* (~500 kcal), not the fruit. For staple
|
||||
whole foods a small, offline, curated table is both more correct and faster.
|
||||
|
||||
So this module gives diet_guard, for each common countable food, the typical
|
||||
mass of one piece and its macros per 100 g. It is consulted *before* Open Food
|
||||
Facts (see :mod:`python_pkg.diet_guard._resolve`), so a bare staple resolves
|
||||
locally and sensibly, and a count multiplies cleanly into grams.
|
||||
|
||||
The numbers are deliberately round "good enough" averages (USDA ballpark); the
|
||||
goal is a sane estimate the user can override with an explicit weight, not lab
|
||||
precision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._fuzzy import match_score
|
||||
|
||||
# Same close-match bar the food bank uses, so matching feels consistent.
|
||||
_MATCH_THRESHOLD = 0.6
|
||||
# Assumed mass of one piece when a counted food is not in the table, so "3 of
|
||||
# something" still produces a number (flagged to the user as an assumption).
|
||||
DEFAULT_ITEM_GRAMS = 100.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Staple:
|
||||
"""A common whole food: typical piece weight and per-100 g macros.
|
||||
|
||||
Attributes:
|
||||
name: Canonical lowercase food name matched against the description.
|
||||
unit_grams: Typical mass of one piece, in grams.
|
||||
kcal_100: Calories per 100 g.
|
||||
protein_100: Protein grams per 100 g.
|
||||
carbs_100: Carbohydrate grams per 100 g.
|
||||
fat_100: Fat grams per 100 g.
|
||||
"""
|
||||
|
||||
name: str
|
||||
unit_grams: float
|
||||
kcal_100: float
|
||||
protein_100: float
|
||||
carbs_100: float
|
||||
fat_100: float
|
||||
|
||||
|
||||
# Per-100 g macros with one typical piece weight, for the common countable
|
||||
# foods. Ordered roughly by how often they are eaten by the piece.
|
||||
_STAPLES: tuple[Staple, ...] = (
|
||||
Staple("apple", 182, 52, 0.3, 14.0, 0.2),
|
||||
Staple("banana", 118, 89, 1.1, 23.0, 0.3),
|
||||
Staple("orange", 131, 47, 0.9, 12.0, 0.1),
|
||||
Staple("egg", 50, 143, 13.0, 1.1, 9.5),
|
||||
Staple("boiled egg", 50, 155, 13.0, 1.1, 11.0),
|
||||
Staple("slice of bread", 28, 265, 9.0, 49.0, 3.2),
|
||||
Staple("potato", 173, 77, 2.0, 17.0, 0.1),
|
||||
Staple("tomato", 123, 18, 0.9, 3.9, 0.2),
|
||||
Staple("carrot", 61, 41, 0.9, 10.0, 0.2),
|
||||
Staple("pear", 178, 57, 0.4, 15.0, 0.1),
|
||||
Staple("peach", 150, 39, 0.9, 10.0, 0.3),
|
||||
Staple("kiwi", 69, 61, 1.1, 15.0, 0.5),
|
||||
Staple("mandarin", 74, 53, 0.8, 13.0, 0.3),
|
||||
Staple("clementine", 74, 47, 0.9, 12.0, 0.2),
|
||||
Staple("plum", 66, 46, 0.7, 11.0, 0.3),
|
||||
Staple("strawberry", 12, 32, 0.7, 7.7, 0.3),
|
||||
Staple("slice of pizza", 107, 266, 11.0, 33.0, 10.0),
|
||||
Staple("rice cake", 9, 387, 8.0, 82.0, 2.8),
|
||||
)
|
||||
|
||||
|
||||
def _best_staple(description: str) -> Staple | None:
|
||||
"""Return the staple best matching ``description``, or None below threshold.
|
||||
|
||||
Args:
|
||||
description: Free-text food name (e.g. ``"apple"``, ``"apples"``).
|
||||
|
||||
Returns:
|
||||
The closest :class:`Staple`, or None if nothing clears the match bar.
|
||||
"""
|
||||
key = description.strip().casefold()
|
||||
if not key:
|
||||
return None
|
||||
best: Staple | None = None
|
||||
best_score = _MATCH_THRESHOLD
|
||||
for staple in _STAPLES:
|
||||
score = match_score(key, staple.name)
|
||||
if score > best_score:
|
||||
best = staple
|
||||
best_score = score
|
||||
return best
|
||||
|
||||
|
||||
def estimate_unit_grams(description: str) -> float | None:
|
||||
"""Return the typical grams of one piece of ``description``, or None.
|
||||
|
||||
Args:
|
||||
description: Free-text food name.
|
||||
|
||||
Returns:
|
||||
The unit weight in grams for a known staple, else None (the caller then
|
||||
falls back to :data:`DEFAULT_ITEM_GRAMS` and tells the user it guessed).
|
||||
"""
|
||||
staple = _best_staple(description)
|
||||
return staple.unit_grams if staple is not None else None
|
||||
|
||||
|
||||
def _staple_to_nutrition(staple: Staple) -> Nutrition:
|
||||
"""Return a staple's per-100 g :class:`Nutrition` (source ``"staple: name"``)."""
|
||||
return Nutrition(
|
||||
kcal=staple.kcal_100,
|
||||
protein_g=staple.protein_100,
|
||||
carbs_g=staple.carbs_100,
|
||||
fat_g=staple.fat_100,
|
||||
grams=100.0,
|
||||
source=f"staple: {staple.name}",
|
||||
)
|
||||
|
||||
|
||||
def staple_nutrition(description: str) -> Nutrition | None:
|
||||
"""Return per-100 g :class:`Nutrition` for a known staple, else None.
|
||||
|
||||
The grams are fixed at 100 so the result is a clean reference basis the
|
||||
caller can rescale to the actual amount eaten via
|
||||
:func:`python_pkg.diet_guard._estimator.scale_nutrition`.
|
||||
|
||||
Args:
|
||||
description: Free-text food name.
|
||||
|
||||
Returns:
|
||||
The staple's per-100 g Nutrition (source ``"staple: <name>"``), or None.
|
||||
"""
|
||||
staple = _best_staple(description)
|
||||
return _staple_to_nutrition(staple) if staple is not None else None
|
||||
|
||||
|
||||
def suggest_staples(
|
||||
query: str,
|
||||
limit: int = 6,
|
||||
) -> list[tuple[str, Nutrition]]:
|
||||
"""Return staples whose name matches ``query``, best match first.
|
||||
|
||||
Used to surface built-in whole foods in the gate's live autocomplete (so
|
||||
typing "apple" suggests the staple immediately, without a separate lookup
|
||||
step), alongside the user's banked foods.
|
||||
|
||||
Args:
|
||||
query: Free-text the user has typed so far.
|
||||
limit: Maximum number of suggestions to return.
|
||||
|
||||
Returns:
|
||||
``(name, per-100 g Nutrition)`` pairs, ranked, at most ``limit`` long.
|
||||
"""
|
||||
key = query.strip().casefold()
|
||||
if not key:
|
||||
return []
|
||||
scored: list[tuple[float, Staple]] = []
|
||||
for staple in _STAPLES:
|
||||
score = match_score(key, staple.name)
|
||||
if score >= _MATCH_THRESHOLD:
|
||||
scored.append((score, staple))
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
return [(staple.name, _staple_to_nutrition(staple)) for _, staple in scored[:limit]]
|
||||
161
diet_guard/_resolve.py
Normal file
161
diet_guard/_resolve.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""Resolve a food description to nutrition, food-bank first, OFF last.
|
||||
|
||||
This is the shared precedence both the CLI and the gate window use so a food is
|
||||
always resolved the same way:
|
||||
|
||||
1. **Manual calories** the user typed -- always honored, always offline. Full
|
||||
macros are recorded too when supplied.
|
||||
2. **The food bank** -- a food the user has logged before is served from local
|
||||
history with its remembered macros (no network).
|
||||
3. **Open Food Facts** -- only for a brand-new food with no manual value, to
|
||||
fill in macros the first time it is seen.
|
||||
|
||||
Keeping Open Food Facts strictly last is what makes the gate offline-safe: a
|
||||
dead endpoint can never stop you logging a manual or already-known food, so the
|
||||
lock can never trap you.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from python_pkg.diet_guard._estimator import (
|
||||
Nutrition,
|
||||
estimate_off,
|
||||
manual,
|
||||
off_candidates,
|
||||
scale_nutrition,
|
||||
)
|
||||
from python_pkg.diet_guard._foodbank import lookup_food, search_foods
|
||||
from python_pkg.diet_guard._portions import staple_nutrition, suggest_staples
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualMacros:
|
||||
"""Calories and optional macros the user typed directly for a food.
|
||||
|
||||
Bundling these keeps :func:`resolve_nutrition` to a short argument list.
|
||||
|
||||
Attributes:
|
||||
kcal: Calories entered directly; when supplied the lookups are skipped.
|
||||
protein: Protein grams to record alongside ``kcal``.
|
||||
carbs: Carbohydrate grams to record alongside ``kcal``.
|
||||
fat: Fat grams to record alongside ``kcal``.
|
||||
per_grams: Reference weight the macros are stated for (e.g. 100 for
|
||||
"per 100 g" off a label). When given, the typed macros are scaled
|
||||
from this basis to the eaten amount; when None they are taken as
|
||||
totals for the portion (back-compatible behaviour).
|
||||
"""
|
||||
|
||||
kcal: float
|
||||
protein: float = 0.0
|
||||
carbs: float = 0.0
|
||||
fat: float = 0.0
|
||||
per_grams: float | None = None
|
||||
|
||||
|
||||
def resolve_nutrition(
|
||||
description: str,
|
||||
*,
|
||||
grams: float | None = None,
|
||||
manual_macros: ManualMacros | None = None,
|
||||
) -> Nutrition | None:
|
||||
"""Resolve ``description`` to a :class:`Nutrition`, or None if unresolvable.
|
||||
|
||||
Args:
|
||||
description: Free-text food name.
|
||||
grams: Amount actually eaten, in grams (used to rescale every source).
|
||||
manual_macros: Calories and macros the user typed directly; when given,
|
||||
they are recorded and the lookups are skipped entirely.
|
||||
|
||||
Returns:
|
||||
The resolved Nutrition, or None only when no manual value was supplied,
|
||||
the food is neither banked nor a known staple, and Open Food Facts
|
||||
produced no usable match.
|
||||
"""
|
||||
if manual_macros is not None:
|
||||
# The typed macros describe ``per_grams`` of food (the label basis);
|
||||
# build that reference, then rescale it to the amount actually eaten so
|
||||
# "200 kcal per 100 g, ate 330 g" logs 660 -- no manual arithmetic.
|
||||
reference_grams = (
|
||||
manual_macros.per_grams if manual_macros.per_grams is not None else grams
|
||||
)
|
||||
reference = manual(
|
||||
manual_macros.kcal,
|
||||
reference_grams,
|
||||
protein_g=manual_macros.protein,
|
||||
carbs_g=manual_macros.carbs,
|
||||
fat_g=manual_macros.fat,
|
||||
)
|
||||
eaten = grams if grams is not None else reference_grams
|
||||
return scale_nutrition(reference, eaten) if eaten is not None else reference
|
||||
banked = lookup_food(description)
|
||||
if banked is not None:
|
||||
# Reuse the remembered macros, rescaled if a different amount was eaten.
|
||||
return scale_nutrition(banked, grams) if grams is not None else banked
|
||||
staple = staple_nutrition(description)
|
||||
if staple is not None:
|
||||
# A known whole food (apple, egg, ...) resolves locally and correctly,
|
||||
# before Open Food Facts whose top "apple" hit is a packaged pastry.
|
||||
return scale_nutrition(staple, grams) if grams is not None else staple
|
||||
return estimate_off(description, grams)
|
||||
|
||||
|
||||
def lookup_candidates(
|
||||
description: str,
|
||||
grams: float | None = None,
|
||||
) -> list[tuple[str, Nutrition]]:
|
||||
"""Return reviewable candidates for a food whose macros must be looked up.
|
||||
|
||||
Used by the gate when the user leaves the calorie field blank: it returns
|
||||
the banked food if known (a single, instant, offline match), otherwise the
|
||||
Open Food Facts alternatives so the user can pick the right product and see
|
||||
where each value comes from. Empty means nothing resolved -- the caller
|
||||
must then ask for a manual calorie value (the offline-safe escape).
|
||||
|
||||
Args:
|
||||
description: Free-text food name the user typed.
|
||||
grams: Portion size in grams, if the user supplied one.
|
||||
|
||||
Returns:
|
||||
``(label, nutrition)`` pairs to show for review; at most one for a
|
||||
banked food, otherwise the OFF candidates in relevance order.
|
||||
"""
|
||||
banked = lookup_food(description)
|
||||
if banked is not None:
|
||||
scaled = scale_nutrition(banked, grams) if grams is not None else banked
|
||||
return [(description, scaled)]
|
||||
staple = staple_nutrition(description)
|
||||
if staple is not None:
|
||||
scaled = scale_nutrition(staple, grams) if grams is not None else staple
|
||||
return [(staple.source, scaled)]
|
||||
return [
|
||||
(nutrition.source, nutrition)
|
||||
for nutrition in off_candidates(description, grams)
|
||||
]
|
||||
|
||||
|
||||
def suggest_foods(
|
||||
query: str,
|
||||
limit: int = 6,
|
||||
) -> list[tuple[str, Nutrition]]:
|
||||
"""Return live autocomplete suggestions: banked foods, then matching staples.
|
||||
|
||||
The user's own logged foods rank first (they are the most likely repeats);
|
||||
built-in staples fill any remaining slots so common whole foods surface even
|
||||
before they have ever been logged. A staple already covered by a banked
|
||||
name is not duplicated.
|
||||
|
||||
Args:
|
||||
query: Free-text the user has typed so far.
|
||||
limit: Maximum number of suggestions to return.
|
||||
|
||||
Returns:
|
||||
``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long.
|
||||
"""
|
||||
results = list(search_foods(query, limit))
|
||||
seen = {name.casefold() for name, _ in results}
|
||||
for name, nutrition in suggest_staples(query, limit):
|
||||
if name.casefold() not in seen:
|
||||
results.append((name, nutrition))
|
||||
return results[:limit]
|
||||
111
diet_guard/_slots.py
Normal file
111
diet_guard/_slots.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Pure meal-slot arithmetic for the diet_guard gate.
|
||||
|
||||
This module is deliberately I/O-free and clock-free: every function is a total
|
||||
function of its ``now`` argument and the configured slot constants, so the
|
||||
fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight
|
||||
reset) are exhaustively unit-testable without mocking the filesystem or the
|
||||
wall clock. The stateful "which slots have I actually logged?" question lives
|
||||
in :mod:`python_pkg.diet_guard._state`; the two are composed in
|
||||
:mod:`python_pkg.diet_guard._gate`.
|
||||
|
||||
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
|
||||
16, 20). A slot is *elapsed* once its hour has arrived and we are still inside
|
||||
the daily enforcement window; an elapsed slot with no logged meal is what makes
|
||||
the gate fire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.diet_guard._constants import (
|
||||
GATE_DAY_START_HOUR,
|
||||
GATE_EATING_END_HOUR,
|
||||
GATE_SLOT_INTERVAL_HOURS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
_HOURS_PER_DAY = 24
|
||||
|
||||
|
||||
def day_slots() -> tuple[int, ...]:
|
||||
"""Return the fixed meal-slot hours for a day, e.g. ``(8, 12, 16, 20)``.
|
||||
|
||||
Slots run from the day-start hour, every interval, up to (but not past) the
|
||||
overnight cutoff. Derived from the constants so changing the cadence in one
|
||||
place reshapes the whole schedule.
|
||||
|
||||
Returns:
|
||||
The slot hours in ascending order.
|
||||
"""
|
||||
return tuple(
|
||||
range(GATE_DAY_START_HOUR, GATE_EATING_END_HOUR, GATE_SLOT_INTERVAL_HOURS)
|
||||
)
|
||||
|
||||
|
||||
def within_enforcement_window(now: datetime) -> bool:
|
||||
"""Return True if ``now`` is inside the daily slot-enforcement window.
|
||||
|
||||
Outside ``[day_start, eating_end)`` the gate never fires, so unlogged slots
|
||||
lapse overnight instead of trapping you at 03:00.
|
||||
|
||||
Args:
|
||||
now: Reference local time.
|
||||
|
||||
Returns:
|
||||
True if slot enforcement is active at ``now``.
|
||||
"""
|
||||
return GATE_DAY_START_HOUR <= now.hour < GATE_EATING_END_HOUR
|
||||
|
||||
|
||||
def elapsed_slots(now: datetime) -> tuple[int, ...]:
|
||||
"""Return today's slots whose hour has arrived as of ``now``.
|
||||
|
||||
Empty outside the enforcement window (before the first slot, or after the
|
||||
overnight cutoff), so the caller never has to special-case the night.
|
||||
|
||||
Args:
|
||||
now: Reference local time.
|
||||
|
||||
Returns:
|
||||
The elapsed slot hours, ascending (possibly empty).
|
||||
"""
|
||||
if not within_enforcement_window(now):
|
||||
return ()
|
||||
return tuple(slot for slot in day_slots() if slot <= now.hour)
|
||||
|
||||
|
||||
def missing_slots(now: datetime, logged: set[int]) -> tuple[int, ...]:
|
||||
"""Return elapsed slots that have not been satisfied by a logged meal.
|
||||
|
||||
Args:
|
||||
now: Reference local time.
|
||||
logged: The set of slot hours already covered by today's log.
|
||||
|
||||
Returns:
|
||||
The unsatisfied elapsed slot hours, ascending (empty == nothing due).
|
||||
"""
|
||||
return tuple(slot for slot in elapsed_slots(now) if slot not in logged)
|
||||
|
||||
|
||||
def current_slot(now: datetime) -> int | None:
|
||||
"""Return the most recent elapsed slot as of ``now``, or None.
|
||||
|
||||
Used to tag a meal logged through the plain ``ate`` CLI with the slot it
|
||||
belongs to, so it counts toward that checkpoint.
|
||||
|
||||
Args:
|
||||
now: Reference local time.
|
||||
|
||||
Returns:
|
||||
The latest elapsed slot hour, or None when none have elapsed yet.
|
||||
"""
|
||||
elapsed = elapsed_slots(now)
|
||||
return elapsed[-1] if elapsed else None
|
||||
|
||||
|
||||
def slot_label(slot: int) -> str:
|
||||
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``."""
|
||||
return f"{slot % _HOURS_PER_DAY:02d}:00"
|
||||
271
diet_guard/_state.py
Normal file
271
diet_guard/_state.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""HMAC-signed daily food log for diet_guard.
|
||||
|
||||
Each meal is stored as an individually HMAC-signed entry, reusing the shared
|
||||
key at ``/etc/workout-locker/hmac.key`` -- the same key the screen locker uses
|
||||
-- so the log is tamper-evident: editing the JSON to fake compliance
|
||||
invalidates the signature. On a system without the shared key, entries are
|
||||
written unsigned and still accepted on read, so the tool degrades gracefully
|
||||
instead of silently losing the data it just wrote.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.diet_guard._budget import daily_budget
|
||||
from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
|
||||
from python_pkg.shared.log_integrity import (
|
||||
compute_entry_hmac,
|
||||
verify_entry_hmac,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# On-disk shape: {"YYYY-MM-DD": [entry, entry, ...]}, newest entry last.
|
||||
DayLog = dict[str, list[dict[str, object]]]
|
||||
|
||||
|
||||
def now_local() -> datetime:
|
||||
"""Return the current time as a timezone-aware local datetime."""
|
||||
return datetime.now(tz=timezone.utc).astimezone()
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
"""Return today's *local* date as ``YYYY-MM-DD``.
|
||||
|
||||
Local, not UTC: "what I ate today" is a local-calendar concept, and a meal
|
||||
eaten late in the evening must not roll into tomorrow's budget.
|
||||
"""
|
||||
return now_local().date().isoformat()
|
||||
|
||||
|
||||
def _entry_float(entry: dict[str, object], key: str) -> float:
|
||||
"""Return ``entry[key]`` coerced to float (0.0 if missing/non-numeric).
|
||||
|
||||
``bool`` is rejected even though it subclasses ``int``: a boolean stored in
|
||||
a calorie or macro field is meaningless and must not count as 1 gram.
|
||||
|
||||
Args:
|
||||
entry: A stored log entry.
|
||||
key: The numeric field name to read.
|
||||
|
||||
Returns:
|
||||
The field as a float, or 0.0 when absent or not a real number.
|
||||
"""
|
||||
value = entry.get(key)
|
||||
if isinstance(value, bool):
|
||||
return 0.0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return 0.0
|
||||
|
||||
|
||||
def entry_kcal(entry: dict[str, object]) -> float:
|
||||
"""Return an entry's calorie count as a float (0.0 if missing/invalid)."""
|
||||
return _entry_float(entry, "kcal")
|
||||
|
||||
|
||||
def _read_raw_log() -> DayLog:
|
||||
"""Read the log file without verification (empty dict on any error)."""
|
||||
if not FOOD_LOG_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
with FOOD_LOG_FILE.open() as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_logger.warning("Cannot read food log %s", FOOD_LOG_FILE)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
result: DayLog = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(key, str) and isinstance(value, list):
|
||||
result[key] = [item for item in value if isinstance(item, dict)]
|
||||
return result
|
||||
|
||||
|
||||
def _write_log(log: DayLog) -> None:
|
||||
"""Persist the full log to disk, creating the data directory if needed."""
|
||||
FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FOOD_LOG_FILE.open("w") as handle:
|
||||
json.dump(log, handle, indent=2)
|
||||
|
||||
|
||||
def _hmac_key_available() -> bool:
|
||||
"""Return True if the shared HMAC key can be loaded for signing."""
|
||||
return compute_entry_hmac({"_probe": True}) is not None
|
||||
|
||||
|
||||
def _entry_is_valid(entry: dict[str, object]) -> bool:
|
||||
"""Return True if an entry is untampered.
|
||||
|
||||
A signed entry must verify against the shared key. An unsigned entry is
|
||||
accepted only when no key is available at all; an unsigned entry on a
|
||||
system that *does* have a key means someone stripped the signature to
|
||||
cheat, so it is rejected.
|
||||
"""
|
||||
if isinstance(entry.get("hmac"), str):
|
||||
return verify_entry_hmac(entry)
|
||||
return not _hmac_key_available()
|
||||
|
||||
|
||||
def log_meal(
|
||||
description: str,
|
||||
nutrition: Nutrition,
|
||||
slot: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Append a signed entry for ``description`` to today's log.
|
||||
|
||||
Args:
|
||||
description: The user's free-text meal description.
|
||||
nutrition: Estimated nutrition for the portion eaten.
|
||||
slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the
|
||||
12:00 checkpoint). When None the entry still counts toward the
|
||||
day's calories but does not mark any slot as logged.
|
||||
|
||||
Returns:
|
||||
The stored entry dict (carrying an ``hmac`` field when a key exists).
|
||||
"""
|
||||
entry: dict[str, object] = {
|
||||
"time": now_local().isoformat(timespec="seconds"),
|
||||
"desc": description,
|
||||
"grams": nutrition.grams,
|
||||
"kcal": nutrition.kcal,
|
||||
"protein_g": nutrition.protein_g,
|
||||
"carbs_g": nutrition.carbs_g,
|
||||
"fat_g": nutrition.fat_g,
|
||||
"source": nutrition.source,
|
||||
}
|
||||
if slot is not None:
|
||||
entry["slot"] = slot
|
||||
signature = compute_entry_hmac(entry)
|
||||
if signature is not None:
|
||||
entry["hmac"] = signature
|
||||
else:
|
||||
_logger.warning("HMAC key unavailable - logging unsigned entry")
|
||||
|
||||
log = _read_raw_log()
|
||||
log.setdefault(_today(), []).append(entry)
|
||||
_write_log(log)
|
||||
return entry
|
||||
|
||||
|
||||
def load_log() -> DayLog:
|
||||
"""Return the log with only valid (untampered) entries retained."""
|
||||
raw = _read_raw_log()
|
||||
verified: DayLog = {}
|
||||
for day, entries in raw.items():
|
||||
kept = [entry for entry in entries if _entry_is_valid(entry)]
|
||||
if kept:
|
||||
verified[day] = kept
|
||||
return verified
|
||||
|
||||
|
||||
def today_entries() -> list[dict[str, object]]:
|
||||
"""Return today's valid log entries (possibly empty)."""
|
||||
return load_log().get(_today(), [])
|
||||
|
||||
|
||||
def today_total_kcal() -> float:
|
||||
"""Return total kcal logged today across valid entries."""
|
||||
total = sum(entry_kcal(entry) for entry in today_entries())
|
||||
return round(total, 1)
|
||||
|
||||
|
||||
def today_total_macros() -> tuple[float, float, float]:
|
||||
"""Return today's total ``(protein_g, carbs_g, fat_g)`` across valid entries.
|
||||
|
||||
Returned as a fixed ``(protein, carbs, fat)`` triple so callers (the gate
|
||||
dashboard, the CLI status) can show how the day's macros are stacking up
|
||||
next to the calorie total.
|
||||
|
||||
Returns:
|
||||
The summed protein, carbohydrate, and fat grams, each rounded to 0.1 g.
|
||||
"""
|
||||
entries = today_entries()
|
||||
protein = sum(_entry_float(entry, "protein_g") for entry in entries)
|
||||
carbs = sum(_entry_float(entry, "carbs_g") for entry in entries)
|
||||
fat = sum(_entry_float(entry, "fat_g") for entry in entries)
|
||||
return round(protein, 1), round(carbs, 1), round(fat, 1)
|
||||
|
||||
|
||||
def logged_slots_today() -> set[int]:
|
||||
"""Return the set of meal-slot hours already covered by today's log.
|
||||
|
||||
Only valid (HMAC-verified) entries count, so stripping entries to dodge a
|
||||
checkpoint makes that slot reappear as unsatisfied -- the fail-closed
|
||||
direction. An entry without a ``slot`` field (e.g. a snack logged with no
|
||||
checkpoint) contributes calories but satisfies no slot.
|
||||
|
||||
Returns:
|
||||
The distinct integer slot hours logged today (possibly empty).
|
||||
"""
|
||||
slots: set[int] = set()
|
||||
for entry in today_entries():
|
||||
value = entry.get("slot")
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
slots.add(value)
|
||||
return slots
|
||||
|
||||
|
||||
def remaining_budget() -> float:
|
||||
"""Return kcal remaining against the sealed budget (may be negative).
|
||||
|
||||
Raises:
|
||||
BudgetError: If the budget is uninitialized or its seal is broken;
|
||||
the caller decides whether to guide the user or fail closed.
|
||||
"""
|
||||
return round(daily_budget() - today_total_kcal(), 1)
|
||||
|
||||
|
||||
def consumption_band() -> str:
|
||||
"""Return a qualitative band for today's intake, never revealing the budget.
|
||||
|
||||
Mirrors how the focus daemon surfaces "at home?" rather than the raw
|
||||
coordinates: the caller learns whether to worry, not the number behind the
|
||||
threshold. The threshold still leaks by boundary-probing (watch the label
|
||||
flip), so this hides the anchor, it does not make the budget unrecoverable.
|
||||
|
||||
Returns:
|
||||
``"OVER BUDGET"``, ``"approaching limit"``, or ``"on track"``.
|
||||
|
||||
Raises:
|
||||
BudgetError: Propagated from :func:`daily_budget` for the caller to
|
||||
translate into guidance.
|
||||
"""
|
||||
budget = daily_budget()
|
||||
consumed = today_total_kcal()
|
||||
if consumed >= budget:
|
||||
return "OVER BUDGET"
|
||||
if consumed >= budget * BUDGET_WARN_FRACTION:
|
||||
return "approaching limit"
|
||||
return "on track"
|
||||
|
||||
|
||||
def undo_last_today() -> dict[str, object] | None:
|
||||
"""Remove and return today's most recently logged entry, if any.
|
||||
|
||||
Operates on the raw log so a mistaken entry can always be removed, even one
|
||||
that would not pass verification.
|
||||
|
||||
Returns:
|
||||
The removed entry, or None if nothing was logged today.
|
||||
"""
|
||||
log = _read_raw_log()
|
||||
today = _today()
|
||||
entries = log.get(today)
|
||||
if not entries:
|
||||
return None
|
||||
removed = entries.pop()
|
||||
if entries:
|
||||
log[today] = entries
|
||||
else:
|
||||
del log[today]
|
||||
_write_log(log)
|
||||
return removed
|
||||
25
diet_guard/diet-guard-gate.service
Normal file
25
diet_guard/diet-guard-gate.service
Normal file
@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=Diet Guard log-to-unlock gate (periodic check)
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# DISPLAY/PYTHONPATH mirror wake-alarm.service: the gate opens a Tk window when a
|
||||
# lock is due, so without DISPLAY it would crash with "no display name and no
|
||||
# $DISPLAY" before it could even check. The command self-checks gate_is_due() and
|
||||
# exits 0 when no lock is needed, so running it every ~30 min is cheap.
|
||||
#
|
||||
# XAUTHORITY pins the X auth cookie path explicitly. It is belt-and-suspenders,
|
||||
# not the fix: when this unit fires at SESSION START (Persistent=true catch-up),
|
||||
# it can beat the display manager writing ~/.Xauthority, so the cookie is simply
|
||||
# absent yet -- pointing at it does not help. That race is handled in Python by
|
||||
# wait_for_display(), which polls the display until it is connectable before
|
||||
# opening the window (previously a session-start launch died on a "couldn't
|
||||
# connect to display" TclError and never showed). The sleep gives the session a
|
||||
# brief head start; the Python wait is what makes it reliable.
|
||||
Environment=DISPLAY=:0
|
||||
Environment=XAUTHORITY=%h/.Xauthority
|
||||
Environment=PYTHONPATH=%h/testsAndMisc
|
||||
ExecStartPre=/bin/sleep 1
|
||||
ExecStart=/usr/bin/python -m python_pkg.diet_guard gate
|
||||
WorkingDirectory=%h/testsAndMisc
|
||||
20
diet_guard/diet-guard-gate.timer
Normal file
20
diet_guard/diet-guard-gate.timer
Normal file
@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Periodically run the Diet Guard gate check
|
||||
|
||||
[Timer]
|
||||
# Check on a WALL-CLOCK schedule (every 30 min, on the hour and half-hour).
|
||||
# The 5-hour "log a meal" threshold and the 08:00-22:00 eating window are
|
||||
# enforced inside gate_is_due(), so the timer only needs to fire often enough to
|
||||
# notice the boundary; 30 min is plenty.
|
||||
#
|
||||
# Why OnCalendar and not OnBootSec/OnUnitActiveSec: the gate is a *blocking*
|
||||
# oneshot -- it stays up until a meal is logged -- and a monotonic schedule
|
||||
# computed no next elapse once the service had run (and none at all across a
|
||||
# reboot), leaving the timer dormant (NextElapse=infinity). OnCalendar re-arms
|
||||
# every period regardless of how long the gate stayed open, and Persistent
|
||||
# catches a run missed while the machine was suspended.
|
||||
OnCalendar=*-*-* *:00/30:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
54
diet_guard/docs/design.md
Normal file
54
diet_guard/docs/design.md
Normal file
@ -0,0 +1,54 @@
|
||||
I turned on pc today and diet guard did not work... pc got turned on at ~18:52 but the diet guard did not show, it should show immediately since there were no meals logged at
|
||||
08:00 12:00 and 16:00 (since pc was turned off)
|
||||
|
||||
How it currently works:
|
||||
It triggers every 5hr if no food was recorded as eaten
|
||||
|
||||
Issue:
|
||||
It is semi-automatic, it assumes user will manually write down food so far, they will not
|
||||
also 5hr is too much
|
||||
|
||||
What it should do:
|
||||
Every 4hrs (STARTING at the "beginning of the day" so currently 8 AM) open a locker asking to fill what food was eaten, do it for every 4hrs (so next at 12:00 next at 16:00 next at 20:00) this has 2 benefits:
|
||||
|
||||
1. is fully automatic
|
||||
2. makes user eat regularly which makes keeping diet easier
|
||||
|
||||
Initially user should write down name of the food, its FULL macro
|
||||
(calories, protein, carbs and fats)
|
||||
the diet_guard should hold this info in a "bank" of food info, so that next time this popup comes user
|
||||
can
|
||||
|
||||
1. Write down the food manually again
|
||||
2. The input should suggest what food do they want to write down (think autocomplete)
|
||||
3. User should be also able to expand list of food and choose from this list, as user writes down in
|
||||
input this list should be filtered to match whatever used wrote down (some smart filtering, not
|
||||
literally "if the food begins with this name" user can make typos, write something similar but
|
||||
not exactly the same and so on) -> this should be a LOCAL database but we should use
|
||||
open food data (or whatever we are using right now) to help us fill it but the search should
|
||||
only use historical data of what the user filled in before
|
||||
|
||||
This every 4hrs process should also inform user how many calories they have left out of total calories
|
||||
for the day
|
||||
|
||||
This is for Friday-Monday INCLUDING, for Tuesday, Wednesday, Thursday it should work a bit different
|
||||
assume that user comebacks late (say 5PM or later)
|
||||
LOCK the screen so user fills out full food intake for this day so far, make this a requiremetn to
|
||||
access the PC
|
||||
after that work as before so show diet lock at specific hour (lets say they come at
|
||||
5PM so probably at 8PM)
|
||||
Ok in fact I think we should not have 2 different processes, make it a one process that accumulates
|
||||
"food times"
|
||||
so lets say user turn on pc before 8AM show nothing and at 8AM show something
|
||||
if user turns on pc after 8AM but before 12:00 show only this one 8AM food time
|
||||
if user turns on lets say 17:00 make them fill data for 8AM, 12:00 and 16:00..
|
||||
and so on
|
||||
|
||||
Another feature would be allowing for complicated "meal" type items, so for example I would like to log that at 12:00 I ate a soup and a meal
|
||||
soup with specific macro
|
||||
and dinner which consisted of
|
||||
salad
|
||||
chicken
|
||||
rice
|
||||
each having their own macro that I would want to fill for all of them separately and make the program
|
||||
calculate the sum of it, both the individual items and the meal itself should be saved to database
|
||||
77
diet_guard/install.sh
Executable file
77
diet_guard/install.sh
Executable file
@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Diet Guard installer: hidden budget + log-to-unlock gate.
|
||||
#
|
||||
# Usage: bash install.sh
|
||||
#
|
||||
# What it does:
|
||||
# 1. Ensures system deps (setxkbmap for VT-disable, requests for OFF lookups)
|
||||
# 2. Installs + enables the systemd user timer that fires the gate every ~30m
|
||||
# 3. Seals your daily budget from biometrics (only if not already sealed)
|
||||
# 4. Locks the budget file immutable with `chattr +i` (the real tamper gate)
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
# python_pkg/diet_guard -> repo root (two levels up).
|
||||
REPO_DIR="$(readlink -f "$SCRIPT_DIR/../..")"
|
||||
readonly REPO_DIR
|
||||
readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service"
|
||||
readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer"
|
||||
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
readonly DATA_DIR="$HOME/.local/share/diet_guard"
|
||||
readonly BUDGET_FILE="$DATA_DIR/.budget"
|
||||
|
||||
echo "=== Diet Guard Installer ==="
|
||||
|
||||
# 1. System dependencies ------------------------------------------------------
|
||||
echo "[1/4] Checking system dependencies..."
|
||||
if ! command -v setxkbmap &>/dev/null; then
|
||||
echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..."
|
||||
sudo pacman -S --noconfirm xorg-setxkbmap
|
||||
else
|
||||
echo " setxkbmap present"
|
||||
fi
|
||||
if ! python -c 'import requests' 2>/dev/null; then
|
||||
echo " Installing python-requests (Open Food Facts lookups)..."
|
||||
sudo pacman -S --noconfirm python-requests
|
||||
else
|
||||
echo " python-requests present"
|
||||
fi
|
||||
|
||||
# 2. systemd user timer + service --------------------------------------------
|
||||
echo "[2/4] Installing systemd user timer + service..."
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
|
||||
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now diet-guard-gate.timer
|
||||
echo " Timer enabled and started (fires the gate every ~30 min)."
|
||||
|
||||
# 3. Seal the daily budget (hidden) ------------------------------------------
|
||||
echo "[3/4] Sealing your daily budget..."
|
||||
if [[ -e "$BUDGET_FILE" ]]; then
|
||||
echo " Budget already sealed at $BUDGET_FILE - skipping init."
|
||||
else
|
||||
echo " Enter your biometrics (used once then discarded; the value is hidden):"
|
||||
(cd "$REPO_DIR" && python -m python_pkg.diet_guard init)
|
||||
fi
|
||||
|
||||
# 4. Lock the budget immutable (the real tamper friction) --------------------
|
||||
echo "[4/4] Locking the budget file (chattr +i)..."
|
||||
read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)"
|
||||
if [[ "$attrs" == *i* ]]; then
|
||||
echo " Already immutable."
|
||||
else
|
||||
sudo chattr +i "$BUDGET_FILE"
|
||||
echo " Locked. To change it later: sudo chattr -i '$BUDGET_FILE'; re-run init; re-lock."
|
||||
fi
|
||||
|
||||
echo "=== Installation complete ==="
|
||||
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
|
||||
echo "once you have gone 5h without logging."
|
||||
echo "Test the lock now (safe, closeable): \
|
||||
cd $REPO_DIR && python -m python_pkg.diet_guard gate --demo"
|
||||
0
diet_guard/tests/__init__.py
Normal file
0
diet_guard/tests/__init__.py
Normal file
69
diet_guard/tests/conftest.py
Normal file
69
diet_guard/tests/conftest.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Shared fixtures for diet_guard tests.
|
||||
|
||||
Two safety nets run for every test:
|
||||
|
||||
* ``_isolate_state`` redirects the food log, sealed budget, and gate lock into
|
||||
``tmp_path`` so a test can never read or clobber the real ``~/.local/share``.
|
||||
* ``_block_real_tk`` swaps ``tk`` and the ``_GateRoot`` window class inside
|
||||
``_gatelock`` for mocks, so no test can open a real fullscreen window or grab
|
||||
the keyboard even if it forgets to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_state(tmp_path: Path) -> Iterator[None]:
|
||||
"""Redirect all on-disk diet_guard state into a temp dir."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.diet_guard._budget.BUDGET_FILE",
|
||||
tmp_path / ".budget",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.diet_guard._state.FOOD_LOG_FILE",
|
||||
tmp_path / "food_log.json",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.diet_guard._foodbank.FOOD_BANK_FILE",
|
||||
tmp_path / "food_bank.json",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.diet_guard._gatelock.GATE_LOCK_FILE",
|
||||
tmp_path / ".gate.lock",
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_real_tk() -> Iterator[None]:
|
||||
"""Replace tk + the window class in _gatelock so no real window can open."""
|
||||
with (
|
||||
patch("python_pkg.diet_guard._gatelock.tk", MagicMock()),
|
||||
patch("python_pkg.diet_guard._gatelock._GateRoot", MagicMock()),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_key(tmp_path: Path) -> Iterator[None]:
|
||||
"""Point the shared HMAC key at a deterministic temp file.
|
||||
|
||||
Makes signing/verification work the same in any environment (including CI,
|
||||
which has no ``/etc/workout-locker/hmac.key``). Tests that need the
|
||||
no-key path patch ``compute_entry_hmac`` to return None locally.
|
||||
"""
|
||||
key = tmp_path / "hmac.key"
|
||||
key.write_bytes(b"diet-guard-test-key-0123456789ab")
|
||||
with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key):
|
||||
yield
|
||||
272
diet_guard/tests/test_budget.py
Normal file
272
diet_guard/tests/test_budget.py
Normal file
@ -0,0 +1,272 @@
|
||||
"""Tests for _budget.py — the hidden, tamper-hardened daily budget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.diet_guard import _budget
|
||||
from python_pkg.diet_guard._budget import (
|
||||
Biometrics,
|
||||
BudgetLockedError,
|
||||
BudgetNotInitializedError,
|
||||
BudgetSealBrokenError,
|
||||
budget_weight,
|
||||
compute_target_budget,
|
||||
daily_budget,
|
||||
is_initialized,
|
||||
lock_command,
|
||||
mifflin_st_jeor_bmr,
|
||||
protein_target_g,
|
||||
seal_budget,
|
||||
unlock_command,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
# A reusable, realistic body profile (the user's own stats).
|
||||
_BIO = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=True)
|
||||
|
||||
|
||||
def _write_record(record: object) -> None:
|
||||
"""Write an arbitrary object as the seal file (for tamper tests)."""
|
||||
_budget.BUDGET_FILE.write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
|
||||
def _budget_open_raises(exc: type[BaseException]) -> object:
|
||||
"""Patch ``Path.open`` to raise ``exc`` ONLY for the sealed-budget file.
|
||||
|
||||
``Path`` instances use ``__slots__`` so ``patch.object(BUDGET_FILE, "open")``
|
||||
fails; and patching ``Path.open`` wholesale would also break the unrelated
|
||||
HMAC-key read inside ``compute_entry_hmac``. Routing every other path to the
|
||||
real ``open`` keeps the failure surgically on the budget file.
|
||||
|
||||
Args:
|
||||
exc: The exception type to raise when the budget file is opened.
|
||||
|
||||
Returns:
|
||||
An unstarted ``patch`` context manager.
|
||||
"""
|
||||
# Capture the real opener as a permissive callable so forwarding the
|
||||
# patched-through args (typed ``object`` here) is not rejected on arg types.
|
||||
real_open = cast("Callable[..., Iterator[str]]", Path.open)
|
||||
|
||||
def fake_open(self: Path, *args: object, **kwargs: object) -> Iterator[str]:
|
||||
if self == _budget.BUDGET_FILE:
|
||||
raise exc
|
||||
return real_open(self, *args, **kwargs)
|
||||
|
||||
return patch("pathlib.Path.open", new=fake_open)
|
||||
|
||||
|
||||
class TestMifflinStJeor:
|
||||
"""The BMR formula's two sex branches."""
|
||||
|
||||
def test_male_constant(self) -> None:
|
||||
"""Male uses the +5 constant."""
|
||||
# 10*80 + 6.25*169 - 5*26 + 5 = 1731.25
|
||||
assert mifflin_st_jeor_bmr(_BIO) == pytest.approx(1731.25)
|
||||
|
||||
def test_female_constant(self) -> None:
|
||||
"""Female uses the -161 constant."""
|
||||
bio = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=False)
|
||||
assert mifflin_st_jeor_bmr(bio) == pytest.approx(1731.25 - 166.0)
|
||||
|
||||
|
||||
class TestComputeTargetBudget:
|
||||
"""TDEE minus deficit, with a safety floor."""
|
||||
|
||||
def test_typical_value(self) -> None:
|
||||
"""A light-activity, modest-deficit target rounds as expected."""
|
||||
# 1731.25 * 1.375 - 180 = 2200.46... -> 2200
|
||||
result = compute_target_budget(_BIO, activity_factor=1.375, deficit_kcal=180)
|
||||
assert result == 2200
|
||||
|
||||
def test_floored_to_minimum(self) -> None:
|
||||
"""An absurd deficit cannot seal a starvation-level budget."""
|
||||
result = compute_target_budget(_BIO, activity_factor=1.0, deficit_kcal=5000)
|
||||
assert result == _budget._MIN_SANE_BUDGET
|
||||
|
||||
|
||||
class TestExceptions:
|
||||
"""Each budget error carries a fixed message."""
|
||||
|
||||
def test_messages(self) -> None:
|
||||
"""Constructors set a non-empty message with no arguments."""
|
||||
assert str(BudgetNotInitializedError())
|
||||
assert str(BudgetSealBrokenError())
|
||||
assert str(BudgetLockedError())
|
||||
|
||||
|
||||
class TestSealAndRead:
|
||||
"""Round-tripping the sealed budget."""
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
"""A sealed value reads back exactly."""
|
||||
seal_budget(2000)
|
||||
assert daily_budget() == 2000
|
||||
|
||||
def test_is_initialized(self) -> None:
|
||||
"""is_initialized reflects whether the file exists."""
|
||||
assert not is_initialized()
|
||||
seal_budget(2000)
|
||||
assert is_initialized()
|
||||
|
||||
def test_file_is_not_plaintext(self) -> None:
|
||||
"""The number is base64-wrapped, not stored as a bare integer."""
|
||||
seal_budget(2345)
|
||||
raw = _budget.BUDGET_FILE.read_text(encoding="utf-8")
|
||||
assert "2345" not in raw
|
||||
|
||||
def test_unsigned_accepted_when_no_key(self) -> None:
|
||||
"""With no HMAC key, an unsigned seal is written and accepted."""
|
||||
with patch.object(_budget, "compute_entry_hmac", return_value=None):
|
||||
seal_budget(1800)
|
||||
record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8"))
|
||||
assert "hmac" not in record
|
||||
assert daily_budget() == 1800
|
||||
|
||||
def test_locked_file_raises(self) -> None:
|
||||
"""An unwritable (immutable) file surfaces as BudgetLockedError."""
|
||||
with _budget_open_raises(PermissionError), pytest.raises(BudgetLockedError):
|
||||
seal_budget(2000)
|
||||
|
||||
|
||||
class TestReadFailures:
|
||||
"""daily_budget's defensive paths."""
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""No file yet -> not initialized."""
|
||||
with pytest.raises(BudgetNotInitializedError):
|
||||
daily_budget()
|
||||
|
||||
def test_unreadable_file(self) -> None:
|
||||
"""An OSError while reading surfaces as a broken seal."""
|
||||
seal_budget(2000)
|
||||
with _budget_open_raises(OSError), pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_invalid_json(self) -> None:
|
||||
"""Garbage content -> broken seal."""
|
||||
_budget.BUDGET_FILE.write_text("not json", encoding="utf-8")
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_record_not_dict(self) -> None:
|
||||
"""A non-object top level -> broken seal."""
|
||||
_write_record([1, 2, 3])
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_data_not_string(self) -> None:
|
||||
"""A non-string data field -> broken seal."""
|
||||
_write_record({"data": 123})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_bad_base64(self) -> None:
|
||||
"""Undecodable base64 -> broken seal."""
|
||||
_write_record({"data": "!!!not base64!!!"})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_inner_not_dict(self) -> None:
|
||||
"""base64 that decodes to a non-object -> broken seal."""
|
||||
inner = base64.b64encode(b"[1,2,3]").decode("ascii")
|
||||
_write_record({"data": inner})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_tampered_signature(self) -> None:
|
||||
"""A forged value with a bad signature is rejected."""
|
||||
forged = base64.b64encode(b'{"b":9999,"v":1}').decode("ascii")
|
||||
_write_record({"data": forged, "hmac": "deadbeef"})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_unsigned_rejected_when_key_available(self) -> None:
|
||||
"""A stripped signature on a keyed system means tampering."""
|
||||
valid = base64.b64encode(b'{"b":2000,"v":1}').decode("ascii")
|
||||
_write_record({"data": valid}) # no hmac, but a key exists
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_signature_present_but_key_missing(self) -> None:
|
||||
"""A signed seal cannot be verified once the key is gone."""
|
||||
seal_budget(2000)
|
||||
with (
|
||||
patch.object(
|
||||
_budget,
|
||||
"compute_entry_hmac",
|
||||
return_value=None,
|
||||
),
|
||||
pytest.raises(BudgetSealBrokenError),
|
||||
):
|
||||
daily_budget()
|
||||
|
||||
def test_non_integer_value(self) -> None:
|
||||
"""A non-integer budget (here a bool) is rejected."""
|
||||
# Sign a record whose inner "b" is a bool, so the signature is valid but
|
||||
# the value type is wrong.
|
||||
inner = {"v": 1, "b": True}
|
||||
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
||||
record = {
|
||||
"data": base64.b64encode(blob).decode("ascii"),
|
||||
"hmac": _budget.compute_entry_hmac(inner),
|
||||
}
|
||||
_write_record(record)
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
|
||||
class TestWeightAndProtein:
|
||||
"""The v2 stored weight and the protein target derived from it."""
|
||||
|
||||
def test_seal_with_weight_roundtrips(self) -> None:
|
||||
"""A weight sealed alongside the budget reads back."""
|
||||
seal_budget(2200, weight_kg=80.0)
|
||||
assert daily_budget() == 2200
|
||||
assert budget_weight() == pytest.approx(80.0)
|
||||
|
||||
def test_protein_target_from_weight(self) -> None:
|
||||
"""The protein target is weight x the per-kg constant."""
|
||||
seal_budget(2200, weight_kg=80.0)
|
||||
expected = round(80.0 * _budget.PROTEIN_G_PER_KG, 1)
|
||||
assert protein_target_g() == pytest.approx(expected)
|
||||
|
||||
def test_v1_seal_has_no_weight(self) -> None:
|
||||
"""A budget sealed without a weight exposes no weight or protein target."""
|
||||
seal_budget(2000)
|
||||
assert budget_weight() is None
|
||||
assert protein_target_g() is None
|
||||
|
||||
def test_protein_target_none_when_uninitialized(self) -> None:
|
||||
"""With nothing sealed, the protein target is quietly None, not an error."""
|
||||
assert protein_target_g() is None
|
||||
|
||||
def test_budget_weight_rejects_non_numeric(self) -> None:
|
||||
"""A validly-signed but non-numeric weight yields None, not a crash."""
|
||||
inner = {"v": 2, "b": 2000, "w": True}
|
||||
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
||||
record = {
|
||||
"data": base64.b64encode(blob).decode("ascii"),
|
||||
"hmac": _budget.compute_entry_hmac(inner),
|
||||
}
|
||||
_write_record(record)
|
||||
assert budget_weight() is None
|
||||
|
||||
|
||||
class TestCommands:
|
||||
"""The chattr helper strings."""
|
||||
|
||||
def test_lock_unlock_commands(self) -> None:
|
||||
"""Both reference the budget path with the right chattr flag."""
|
||||
assert lock_command().startswith("sudo chattr +i ")
|
||||
assert unlock_command().startswith("sudo chattr -i ")
|
||||
assert str(_budget.BUDGET_FILE) in lock_command()
|
||||
279
diet_guard/tests/test_cli.py
Normal file
279
diet_guard/tests/test_cli.py
Normal file
@ -0,0 +1,279 @@
|
||||
"""Tests for _cli.py — argument parsing and subcommand dispatch.
|
||||
|
||||
Subsystems (budget, resolution, logging, the gate window) are mocked so each
|
||||
command's branches are exercised without touching real state or opening a
|
||||
window; stdin is scripted via ``StringIO`` and stdout captured with ``capsys``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.diet_guard import _cli
|
||||
from python_pkg.diet_guard._budget import (
|
||||
BudgetLockedError,
|
||||
BudgetNotInitializedError,
|
||||
BudgetSealBrokenError,
|
||||
seal_budget,
|
||||
)
|
||||
from python_pkg.diet_guard._cli import _eaten_grams, _Portion, main
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
_NUT = Nutrition(250, 12, 30, 10, 200, "manual")
|
||||
_VALID_INIT = "80\n169\n26\nm\n1.375\n180\n"
|
||||
|
||||
|
||||
def _feed(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
|
||||
"""Point stdin at scripted ``text`` for the prompts a command reads."""
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO(text))
|
||||
|
||||
|
||||
class TestEatenGrams:
|
||||
"""Turning a portion into grams, with the assumption note."""
|
||||
|
||||
def test_count_of_known_staple(self) -> None:
|
||||
"""A count of a known staple multiplies by its unit weight, no note."""
|
||||
grams, note = _eaten_grams(
|
||||
"apple", _Portion(grams=None, count=5, per_grams=None)
|
||||
)
|
||||
assert grams == 5 * 182
|
||||
assert note is None
|
||||
|
||||
def test_count_of_unknown_item_warns(self) -> None:
|
||||
"""A count of an unknown item uses the default and flags the assumption."""
|
||||
grams, note = _eaten_grams(
|
||||
"mystery", _Portion(grams=None, count=3, per_grams=None)
|
||||
)
|
||||
assert grams is not None
|
||||
assert note is not None
|
||||
|
||||
def test_explicit_grams(self) -> None:
|
||||
"""An explicit gram portion passes straight through."""
|
||||
grams, note = _eaten_grams("x", _Portion(grams=300, count=None, per_grams=None))
|
||||
assert grams == 300
|
||||
assert note is None
|
||||
|
||||
|
||||
class TestInit:
|
||||
"""The budget-sealing init command."""
|
||||
|
||||
def test_valid_male(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Valid inputs seal a budget and print the lock hint, not the number."""
|
||||
_feed(monkeypatch, _VALID_INIT)
|
||||
assert main(["init"]) == 0
|
||||
assert "sealed" in capsys.readouterr().out
|
||||
|
||||
def test_valid_female(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The female sex branch is accepted."""
|
||||
_feed(monkeypatch, "80\n169\n26\nf\n1.375\n180\n")
|
||||
assert main(["init"]) == 0
|
||||
|
||||
def test_non_number_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A non-numeric input seals nothing and returns the error code."""
|
||||
_feed(monkeypatch, "heavy\n")
|
||||
assert main(["init"]) == 2
|
||||
|
||||
def test_bad_sex_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An unrecognised sex answer seals nothing."""
|
||||
_feed(monkeypatch, "80\n169\n26\nx\n1.375\n180\n")
|
||||
assert main(["init"]) == 2
|
||||
|
||||
def test_locked_budget(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A locked file surfaces the unlock instructions and a failure code."""
|
||||
_feed(monkeypatch, _VALID_INIT)
|
||||
with patch.object(_cli, "seal_budget", side_effect=BudgetLockedError):
|
||||
assert main(["init"]) == 1
|
||||
assert "locked" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestSummary:
|
||||
"""The budget-remaining summary line."""
|
||||
|
||||
def test_not_initialized(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""No budget yet -> a guiding hint, no crash."""
|
||||
with patch.object(_cli, "daily_budget", side_effect=BudgetNotInitializedError):
|
||||
_cli._print_summary()
|
||||
assert "budget not set" in capsys.readouterr().out
|
||||
|
||||
def test_seal_broken(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A broken seal is reported plainly."""
|
||||
with patch.object(_cli, "daily_budget", side_effect=BudgetSealBrokenError):
|
||||
_cli._print_summary()
|
||||
assert "seal broken" in capsys.readouterr().out
|
||||
|
||||
def test_remaining_shown(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A valid budget prints how much is left."""
|
||||
seal_budget(2000)
|
||||
_cli._print_summary()
|
||||
assert "left" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestAte:
|
||||
"""Logging a meal from the command line."""
|
||||
|
||||
def test_logs_and_summarizes(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A resolved meal is logged, banked, and summarized."""
|
||||
seal_budget(2000)
|
||||
with patch.object(_cli, "resolve_nutrition", return_value=_NUT):
|
||||
assert main(["ate", "big mac"]) == 0
|
||||
assert "logged:" in capsys.readouterr().out
|
||||
|
||||
def test_note_printed_for_assumed_weight(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""An assumed per-item weight prints its caveat."""
|
||||
seal_budget(2000)
|
||||
with patch.object(_cli, "resolve_nutrition", return_value=_NUT):
|
||||
main(["ate", "mystery", "--count", "3"])
|
||||
assert "assumed" in capsys.readouterr().out
|
||||
|
||||
def test_unresolved_food(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An unresolvable food returns a failure and a manual-entry hint."""
|
||||
with patch.object(_cli, "resolve_nutrition", return_value=None):
|
||||
assert main(["ate", "nonsense"]) == 1
|
||||
assert "--kcal" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestStatus:
|
||||
"""The status report."""
|
||||
|
||||
def test_status_with_entries(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Logged entries, slots, summary, and macros all print."""
|
||||
seal_budget(2000)
|
||||
main(["ate", "lunch", "--kcal", "500"])
|
||||
capsys.readouterr()
|
||||
assert main(["status"]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "slots:" in out
|
||||
assert "macros:" in out
|
||||
|
||||
def test_status_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""With nothing logged, status still prints the slot/summary lines."""
|
||||
seal_budget(2000)
|
||||
assert main(["status"]) == 0
|
||||
assert "slots:" in capsys.readouterr().out
|
||||
|
||||
def test_macro_status_with_target(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""When a protein target is known, it is shown alongside the macros."""
|
||||
with patch.object(_cli, "protein_target_g", return_value=144.0):
|
||||
_cli._print_macro_status()
|
||||
assert "protein" in capsys.readouterr().out
|
||||
|
||||
def test_macro_status_without_target(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""With no target, only the running macros are shown."""
|
||||
with patch.object(_cli, "protein_target_g", return_value=None):
|
||||
_cli._print_macro_status()
|
||||
out = capsys.readouterr().out
|
||||
assert "macros:" in out
|
||||
assert "protein" not in out
|
||||
|
||||
def test_slot_status_all_marks(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The slot line shows logged / DUE / upcoming together."""
|
||||
with (
|
||||
patch.object(_cli, "logged_slots_today", return_value={8}),
|
||||
patch.object(_cli, "due_slots", return_value=[12]),
|
||||
):
|
||||
_cli._print_slot_status()
|
||||
out = capsys.readouterr().out
|
||||
assert "logged" in out
|
||||
assert "DUE" in out
|
||||
assert "upcoming" in out
|
||||
|
||||
|
||||
class TestUndo:
|
||||
"""Removing the most recent entry."""
|
||||
|
||||
def test_nothing_to_undo(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An empty day reports nothing to undo."""
|
||||
assert main(["undo"]) == 0
|
||||
assert "nothing to undo" in capsys.readouterr().out
|
||||
|
||||
def test_undo_removes_entry(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Undo removes and reports the last entry."""
|
||||
seal_budget(2000)
|
||||
main(["ate", "snack", "--kcal", "100"])
|
||||
capsys.readouterr()
|
||||
assert main(["undo"]) == 0
|
||||
assert "removed:" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestGate:
|
||||
"""The gate subcommand's three modes."""
|
||||
|
||||
def test_check_due(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""--check exits 1 and announces a due lock."""
|
||||
with patch.object(_cli, "gate_is_due", return_value=True):
|
||||
assert main(["gate", "--check"]) == 1
|
||||
assert "due" in capsys.readouterr().out
|
||||
|
||||
def test_check_not_due(self) -> None:
|
||||
"""--check exits 0 when no lock is needed."""
|
||||
with patch.object(_cli, "gate_is_due", return_value=False):
|
||||
assert main(["gate", "--check"]) == 0
|
||||
|
||||
def test_demo_opens_window(self) -> None:
|
||||
"""--demo always builds and runs the gate window."""
|
||||
gate = MagicMock()
|
||||
with (
|
||||
patch.object(_cli, "MealGate", return_value=gate) as factory,
|
||||
patch.object(_cli, "acquire_gate_lock", return_value=MagicMock()),
|
||||
patch.object(_cli, "release_gate_lock"),
|
||||
):
|
||||
assert main(["gate", "--demo"]) == 0
|
||||
factory.assert_called_once_with(demo_mode=True)
|
||||
gate.run.assert_called_once()
|
||||
|
||||
def test_bare_gate_not_due(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A bare gate with nothing due just reports and exits."""
|
||||
with patch.object(_cli, "gate_is_due", return_value=False):
|
||||
assert main(["gate"]) == 0
|
||||
assert "no lock needed" in capsys.readouterr().out
|
||||
|
||||
def test_bare_gate_due_opens_window(self) -> None:
|
||||
"""A bare gate that is due opens the real window."""
|
||||
gate = MagicMock()
|
||||
with (
|
||||
patch.object(_cli, "gate_is_due", return_value=True),
|
||||
patch.object(_cli, "MealGate", return_value=gate),
|
||||
patch.object(_cli, "acquire_gate_lock", return_value=MagicMock()),
|
||||
patch.object(_cli, "release_gate_lock"),
|
||||
):
|
||||
assert main(["gate"]) == 0
|
||||
gate.run.assert_called_once()
|
||||
|
||||
def test_gate_already_running(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A held single-instance lock means a second window is not opened."""
|
||||
with (
|
||||
patch.object(_cli, "gate_is_due", return_value=True),
|
||||
patch.object(_cli, "acquire_gate_lock", return_value=None),
|
||||
patch.object(_cli, "MealGate") as factory,
|
||||
):
|
||||
assert main(["gate"]) == 0
|
||||
factory.assert_not_called()
|
||||
assert "already running" in capsys.readouterr().out
|
||||
|
||||
def test_gate_due_but_display_not_ready_defers(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A due gate whose display never comes up defers without a window."""
|
||||
with (
|
||||
patch.object(_cli, "gate_is_due", return_value=True),
|
||||
patch.object(_cli, "acquire_gate_lock", return_value=MagicMock()),
|
||||
patch.object(_cli, "release_gate_lock"),
|
||||
patch.object(_cli, "wait_for_display", return_value=False),
|
||||
patch.object(_cli, "MealGate") as factory,
|
||||
):
|
||||
assert main(["gate"]) == 0
|
||||
factory.assert_not_called()
|
||||
assert "display not ready" in capsys.readouterr().out
|
||||
220
diet_guard/tests/test_estimator.py
Normal file
220
diet_guard/tests/test_estimator.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""Tests for _estimator.py — Nutrition maths and the Open Food Facts backend.
|
||||
|
||||
The HTTP layer is fully mocked (``requests.get``), so the parsing, portion, and
|
||||
scaling branches are exercised without any network access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from python_pkg.diet_guard import _estimator
|
||||
from python_pkg.diet_guard._constants import DEFAULT_PORTION_GRAMS
|
||||
from python_pkg.diet_guard._estimator import (
|
||||
Nutrition,
|
||||
estimate,
|
||||
estimate_off,
|
||||
manual,
|
||||
off_candidates,
|
||||
scale_nutrition,
|
||||
)
|
||||
|
||||
_GOOD = {
|
||||
"product_name": "Big Mac",
|
||||
"nutriments": {
|
||||
"energy-kcal_100g": 250,
|
||||
"proteins_100g": 12,
|
||||
"carbohydrates_100g": 30,
|
||||
"fat_100g": 10,
|
||||
},
|
||||
"serving_quantity": 150,
|
||||
}
|
||||
|
||||
|
||||
def _patch_get(payload: object) -> object:
|
||||
"""Patch ``requests.get`` to return a response whose JSON is ``payload``."""
|
||||
response = MagicMock()
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=payload)
|
||||
return patch.object(_estimator.requests, "get", return_value=response)
|
||||
|
||||
|
||||
def _hits(*products: object) -> dict[str, object]:
|
||||
"""Wrap products in the Search-a-licious ``hits`` envelope."""
|
||||
return {"hits": list(products)}
|
||||
|
||||
|
||||
class TestAsFloat:
|
||||
"""Coercion of OFF numeric fields, including the rejected types."""
|
||||
|
||||
def test_bool_rejected(self) -> None:
|
||||
"""A bool is not a real nutriment value."""
|
||||
assert _estimator._as_float(value=True) is None
|
||||
|
||||
def test_int_and_float(self) -> None:
|
||||
"""Ints and floats pass straight through."""
|
||||
assert _estimator._as_float(5) == 5.0
|
||||
assert _estimator._as_float(2.5) == 2.5
|
||||
|
||||
def test_numeric_string(self) -> None:
|
||||
"""A numeric string parses."""
|
||||
assert _estimator._as_float("3.5") == 3.5
|
||||
|
||||
def test_non_numeric_string(self) -> None:
|
||||
"""A non-numeric string is None."""
|
||||
assert _estimator._as_float("abc") is None
|
||||
|
||||
def test_other_type(self) -> None:
|
||||
"""An unrelated type (None) is None."""
|
||||
assert _estimator._as_float(None) is None
|
||||
|
||||
|
||||
class TestManual:
|
||||
"""User-supplied nutrition."""
|
||||
|
||||
def test_with_grams(self) -> None:
|
||||
"""Grams are kept for display; source is manual."""
|
||||
result = manual(500, 250, protein_g=20, carbs_g=40, fat_g=15)
|
||||
assert result == Nutrition(500.0, 20.0, 40.0, 15.0, 250.0, "manual")
|
||||
|
||||
def test_without_grams(self) -> None:
|
||||
"""Omitting grams records 0.0."""
|
||||
assert manual(300).grams == 0.0
|
||||
|
||||
|
||||
class TestScaleNutrition:
|
||||
"""Proportional rescaling and its degenerate guards."""
|
||||
|
||||
def test_normal_scaling(self) -> None:
|
||||
"""Doubling the grams doubles every macro."""
|
||||
base = Nutrition(100, 10, 5, 2, 100, "x")
|
||||
scaled = scale_nutrition(base, 200)
|
||||
assert (scaled.kcal, scaled.protein_g, scaled.grams) == (200.0, 20.0, 200.0)
|
||||
|
||||
def test_unknown_basis_keeps_macros(self) -> None:
|
||||
"""A zero basis cannot scale, so macros stay and only grams update."""
|
||||
base = Nutrition(100, 10, 5, 2, 0, "x")
|
||||
scaled = scale_nutrition(base, 250)
|
||||
assert scaled.kcal == 100
|
||||
assert scaled.grams == 250
|
||||
|
||||
def test_non_positive_new_grams_keeps_basis_grams(self) -> None:
|
||||
"""A non-positive target weight keeps the basis weight, macros intact."""
|
||||
base = Nutrition(100, 10, 5, 2, 100, "x")
|
||||
scaled = scale_nutrition(base, 0)
|
||||
assert scaled.kcal == 100
|
||||
assert scaled.grams == 100
|
||||
|
||||
|
||||
class TestOffSearchEnvelope:
|
||||
"""Defensive parsing of the search payload shape."""
|
||||
|
||||
def test_payload_not_dict(self) -> None:
|
||||
"""A non-object payload yields no candidates."""
|
||||
with _patch_get("not a dict"):
|
||||
assert off_candidates("x") == []
|
||||
|
||||
def test_hits_not_list(self) -> None:
|
||||
"""A non-list ``hits`` yields no candidates."""
|
||||
with _patch_get({"hits": 123}):
|
||||
assert off_candidates("x") == []
|
||||
|
||||
|
||||
class TestOffCandidates:
|
||||
"""Building Nutrition from products, with filtering and portions."""
|
||||
|
||||
def test_filters_unusable_products(self) -> None:
|
||||
"""Non-dict hits, bad nutriments, and kcal-less products are dropped."""
|
||||
with _patch_get(
|
||||
_hits(
|
||||
"junk-string",
|
||||
{"product_name": "NoNutr", "nutriments": "bad"},
|
||||
{"product_name": "NoKcal", "nutriments": {"proteins_100g": 5}},
|
||||
_GOOD,
|
||||
),
|
||||
):
|
||||
results = off_candidates("big mac")
|
||||
assert len(results) == 1
|
||||
assert results[0].source == "openfoodfacts: Big Mac"
|
||||
|
||||
def test_explicit_grams_override_serving(self) -> None:
|
||||
"""An explicit portion takes priority over the serving size."""
|
||||
with _patch_get(_hits(_GOOD)):
|
||||
result = off_candidates("big mac", grams=200)[0]
|
||||
assert result.grams == 200
|
||||
assert result.kcal == 500
|
||||
|
||||
def test_serving_quantity_used_when_no_grams(self) -> None:
|
||||
"""With no grams, the product's serving size sets the portion."""
|
||||
with _patch_get(_hits(_GOOD)):
|
||||
result = off_candidates("big mac")[0]
|
||||
assert result.grams == 150
|
||||
|
||||
def test_default_portion_when_nothing_known(self) -> None:
|
||||
"""No grams and no serving falls back to the default portion."""
|
||||
product = {"product_name": "P", "nutriments": {"energy-kcal_100g": 100}}
|
||||
with _patch_get(_hits(product)):
|
||||
result = off_candidates("p")[0]
|
||||
assert result.grams == DEFAULT_PORTION_GRAMS
|
||||
|
||||
def test_blank_name_uses_description(self) -> None:
|
||||
"""A blank product name falls back to the typed description."""
|
||||
product = {"product_name": " ", "nutriments": {"energy-kcal_100g": 100}}
|
||||
with _patch_get(_hits(product)):
|
||||
result = off_candidates("my food")[0]
|
||||
assert result.source == "openfoodfacts: my food"
|
||||
|
||||
def test_missing_macro_field_is_zero(self) -> None:
|
||||
"""A product missing a macro records that macro as 0.0."""
|
||||
product = {"product_name": "P", "nutriments": {"energy-kcal_100g": 100}}
|
||||
with _patch_get(_hits(product)):
|
||||
result = off_candidates("p")[0]
|
||||
assert result.protein_g == 0.0
|
||||
|
||||
def test_request_exception_returns_empty(self) -> None:
|
||||
"""A network failure degrades to an empty candidate list."""
|
||||
with patch.object(
|
||||
_estimator.requests,
|
||||
"get",
|
||||
side_effect=requests.RequestException("boom"),
|
||||
):
|
||||
assert off_candidates("x") == []
|
||||
|
||||
|
||||
class TestEstimateOff:
|
||||
"""The single-best-match convenience wrapper."""
|
||||
|
||||
def test_returns_top(self) -> None:
|
||||
"""The top candidate is returned when one exists."""
|
||||
with _patch_get(_hits(_GOOD)):
|
||||
assert estimate_off("big mac", None) is not None
|
||||
|
||||
def test_none_when_empty(self) -> None:
|
||||
"""No matches -> None."""
|
||||
with _patch_get(_hits()):
|
||||
assert estimate_off("nothing", None) is None
|
||||
|
||||
|
||||
class TestEstimate:
|
||||
"""The top-level estimate dispatcher."""
|
||||
|
||||
def test_manual_takes_precedence(self) -> None:
|
||||
"""A manual kcal value skips Open Food Facts entirely."""
|
||||
result = estimate("anything", manual_kcal=222)
|
||||
assert result is not None
|
||||
assert result.source == "manual"
|
||||
|
||||
def test_falls_back_to_off(self) -> None:
|
||||
"""With no manual value, OFF is queried."""
|
||||
with _patch_get(_hits(_GOOD)):
|
||||
result = estimate("big mac")
|
||||
assert result is not None
|
||||
assert "openfoodfacts" in result.source
|
||||
|
||||
|
||||
def test_nutrition_is_immutable() -> None:
|
||||
"""The Nutrition value object is frozen (a dataclass safety check)."""
|
||||
nutrition = Nutrition(1, 2, 3, 4, 5, "x")
|
||||
assert nutrition.kcal == 1
|
||||
206
diet_guard/tests/test_foodbank.py
Normal file
206
diet_guard/tests/test_foodbank.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Tests for _foodbank.py — the local corpus of previously logged foods.
|
||||
|
||||
The food-bank file is redirected into ``tmp_path`` by the autouse conftest
|
||||
fixture, so every read/write here is isolated from real user data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from python_pkg.diet_guard import _foodbank
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._foodbank import (
|
||||
lookup_food,
|
||||
remember_food,
|
||||
remember_meal,
|
||||
search_foods,
|
||||
)
|
||||
from python_pkg.diet_guard._meal import MealItem
|
||||
|
||||
_NUT = Nutrition(
|
||||
kcal=250,
|
||||
protein_g=12,
|
||||
carbs_g=30,
|
||||
fat_g=10,
|
||||
grams=200,
|
||||
source="manual",
|
||||
)
|
||||
|
||||
|
||||
def _write_raw(bank: object) -> None:
|
||||
"""Write an arbitrary object as the bank file (for defensive-read tests)."""
|
||||
_foodbank.FOOD_BANK_FILE.write_text(json.dumps(bank), encoding="utf-8")
|
||||
|
||||
|
||||
class TestAsFloat:
|
||||
"""Field coercion with the bool rejection."""
|
||||
|
||||
def test_bool_is_zero(self) -> None:
|
||||
"""A bool is not a real count/macro."""
|
||||
assert _foodbank._as_float(value=True) == 0.0
|
||||
|
||||
def test_number_passes(self) -> None:
|
||||
"""Ints and floats pass through."""
|
||||
assert _foodbank._as_float(7) == 7.0
|
||||
|
||||
def test_other_is_zero(self) -> None:
|
||||
"""A non-numeric value defaults to 0.0."""
|
||||
assert _foodbank._as_float("x") == 0.0
|
||||
|
||||
|
||||
class TestRememberAndLookup:
|
||||
"""Round-tripping foods through the bank."""
|
||||
|
||||
def test_blank_description_ignored(self) -> None:
|
||||
"""A blank name is not stored."""
|
||||
remember_food(" ", _NUT)
|
||||
assert lookup_food(" ") is None
|
||||
|
||||
def test_roundtrip_case_insensitive(self) -> None:
|
||||
"""A remembered food is found regardless of case."""
|
||||
remember_food("Big Mac", _NUT)
|
||||
found = lookup_food("big mac")
|
||||
assert found is not None
|
||||
assert found.kcal == 250
|
||||
assert found.source == "food bank"
|
||||
|
||||
def test_lookup_miss(self) -> None:
|
||||
"""An unknown food looks up to None."""
|
||||
assert lookup_food("nope") is None
|
||||
|
||||
def test_recording_twice_bumps_count(self) -> None:
|
||||
"""Re-logging a food increments its use count (raises its ranking)."""
|
||||
remember_food("oats", _NUT)
|
||||
remember_food("oats", _NUT)
|
||||
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
|
||||
assert bank["oats"]["count"] == 2
|
||||
|
||||
|
||||
class TestReadDefensive:
|
||||
"""The bank read tolerates a missing or corrupt file."""
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""No file yet -> empty results."""
|
||||
assert search_foods("anything") == []
|
||||
|
||||
def test_corrupt_json(self) -> None:
|
||||
"""Unparsable content -> empty bank."""
|
||||
_foodbank.FOOD_BANK_FILE.write_text("not json", encoding="utf-8")
|
||||
assert search_foods("x") == []
|
||||
|
||||
def test_top_level_not_dict(self) -> None:
|
||||
"""A non-object top level -> empty bank."""
|
||||
_write_raw([1, 2, 3])
|
||||
assert search_foods("x") == []
|
||||
|
||||
def test_non_dict_records_filtered(self) -> None:
|
||||
"""Records that are not objects are dropped on read."""
|
||||
_write_raw({"good": {"desc": "good", "kcal": 5, "count": 1}, "bad": 123})
|
||||
names = [name for name, _ in search_foods("")]
|
||||
assert names == ["good"]
|
||||
|
||||
|
||||
class TestSearch:
|
||||
"""Ranked autocomplete search."""
|
||||
|
||||
def test_empty_query_ranks_by_count(self) -> None:
|
||||
"""An empty query returns all foods, most-logged first."""
|
||||
remember_food("rare", _NUT)
|
||||
remember_food("common", _NUT)
|
||||
remember_food("common", _NUT)
|
||||
names = [name for name, _ in search_foods("")]
|
||||
assert names[0] == "common"
|
||||
|
||||
def test_substring_match(self) -> None:
|
||||
"""A substring of a stored name matches it."""
|
||||
remember_food("chicken breast", _NUT)
|
||||
names = [name for name, _ in search_foods("breast")]
|
||||
assert "chicken breast" in names
|
||||
|
||||
def test_typo_within_threshold(self) -> None:
|
||||
"""A close typo still matches via the fuzzy scorer."""
|
||||
remember_food("chicken", _NUT)
|
||||
names = [name for name, _ in search_foods("chiken")]
|
||||
assert "chicken" in names
|
||||
|
||||
def test_below_threshold_filtered(self) -> None:
|
||||
"""A wildly different query returns nothing."""
|
||||
remember_food("chicken", _NUT)
|
||||
assert search_foods("xylophone") == []
|
||||
|
||||
def test_display_name_falls_back_to_key(self) -> None:
|
||||
"""A record with no usable desc displays under its key."""
|
||||
_write_raw({"applekey": {"kcal": 50, "count": 1}})
|
||||
names = [name for name, _ in search_foods("")]
|
||||
assert names == ["applekey"]
|
||||
|
||||
|
||||
class TestRememberMeal:
|
||||
"""Banking a composite meal and its components."""
|
||||
|
||||
def test_banks_each_item_and_the_composite(self) -> None:
|
||||
"""Every component and the summed meal land in the bank."""
|
||||
items = [
|
||||
MealItem("salad", Nutrition(80, 2, 8, 5, 120, "manual")),
|
||||
MealItem("chicken", Nutrition(330, 62, 0, 7, 200, "manual")),
|
||||
]
|
||||
total = remember_meal("dinner", items)
|
||||
assert total.kcal == 410
|
||||
assert lookup_food("salad") is not None
|
||||
assert lookup_food("chicken") is not None
|
||||
dinner = lookup_food("dinner")
|
||||
assert dinner is not None
|
||||
assert dinner.kcal == 410
|
||||
|
||||
def test_composite_records_components(self) -> None:
|
||||
"""The meal entry carries its component names for later use."""
|
||||
item = MealItem("rice", Nutrition(260, 5, 56, 1, 180, "manual"))
|
||||
remember_meal("bowl", [item])
|
||||
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
|
||||
assert bank["bowl"]["components"] == ["rice"]
|
||||
|
||||
def test_blank_name_banks_items_only(self) -> None:
|
||||
"""A blank meal name still banks items but stores no empty composite."""
|
||||
item = MealItem("toast", Nutrition(120, 4, 20, 2, 40, "manual"))
|
||||
remember_meal(" ", [item])
|
||||
assert lookup_food("toast") is not None
|
||||
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
|
||||
assert list(bank) == ["toast"]
|
||||
|
||||
|
||||
class TestCorruptQuarantine:
|
||||
"""A corrupt bank is moved aside, not re-warned about or overwritten."""
|
||||
|
||||
def test_corrupt_file_is_moved_aside(self) -> None:
|
||||
"""Reading a corrupt bank quarantines it and returns empty."""
|
||||
_foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8")
|
||||
assert _foodbank._read_bank() == {}
|
||||
assert not _foodbank.FOOD_BANK_FILE.exists()
|
||||
backups = list(
|
||||
_foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*"),
|
||||
)
|
||||
assert len(backups) == 1
|
||||
assert backups[0].read_text(encoding="utf-8") == "{ broken"
|
||||
|
||||
def test_subsequent_reads_silent_and_empty(self) -> None:
|
||||
"""After quarantine the next reads find no file (no warning flood)."""
|
||||
_foodbank.FOOD_BANK_FILE.write_text("nope", encoding="utf-8")
|
||||
assert _foodbank._read_bank() == {}
|
||||
assert _foodbank._read_bank() == {}
|
||||
assert _foodbank._read_bank() == {}
|
||||
|
||||
def test_corrupt_then_remember_starts_fresh(self) -> None:
|
||||
"""A new entry after corruption writes a fresh bank, losing nothing."""
|
||||
_foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8")
|
||||
remember_food("eggs", _NUT)
|
||||
assert lookup_food("eggs") is not None
|
||||
assert list(_foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*"))
|
||||
|
||||
def test_rename_failure_is_handled(self) -> None:
|
||||
"""If the corrupt file cannot be moved, the read still returns empty."""
|
||||
_foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8")
|
||||
with patch.object(Path, "rename", side_effect=OSError("locked")):
|
||||
assert _foodbank._read_bank() == {}
|
||||
46
diet_guard/tests/test_fuzzy.py
Normal file
46
diet_guard/tests/test_fuzzy.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Tests for _fuzzy.py — token-aware fuzzy matching.
|
||||
|
||||
Covers both the substring fast path and the per-word token scorer, including
|
||||
the degenerate empty-input branch that falls back to a whole-string ratio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_pkg.diet_guard._fuzzy import match_score, token_score
|
||||
|
||||
|
||||
class TestTokenScore:
|
||||
"""The per-word best-match averaging scorer."""
|
||||
|
||||
def test_empty_query_falls_back_to_ratio(self) -> None:
|
||||
"""An empty query has no words, so a whole-string ratio is used (0.0)."""
|
||||
assert token_score("", "apple") == 0.0
|
||||
|
||||
def test_empty_name_falls_back_to_ratio(self) -> None:
|
||||
"""An empty name has no words, so the ratio path runs."""
|
||||
assert token_score("apple", "") == 0.0
|
||||
|
||||
def test_perfect_word_match(self) -> None:
|
||||
"""Identical single words score 1.0."""
|
||||
assert token_score("apple", "apple") == 1.0
|
||||
|
||||
def test_typo_word_scores_high(self) -> None:
|
||||
"""A near-miss word (beast/breast) scores well above the 0.6 bar."""
|
||||
assert token_score("beast", "breast") > 0.8
|
||||
|
||||
def test_multiword_averages_best_per_word(self) -> None:
|
||||
"""Each query word takes its best name word; the mean is in (0, 1)."""
|
||||
score = token_score("grilled chicken", "chicken breast")
|
||||
assert 0.0 < score < 1.0
|
||||
|
||||
|
||||
class TestMatchScore:
|
||||
"""Substring containment first, then the token scorer."""
|
||||
|
||||
def test_substring_beats_one(self) -> None:
|
||||
"""A contained query scores above 1.0 (1 + coverage fraction)."""
|
||||
assert match_score("breast", "chicken breast") > 1.0
|
||||
|
||||
def test_non_substring_uses_token_score(self) -> None:
|
||||
"""A typo that is not a substring routes to the token scorer (< 1.0)."""
|
||||
assert match_score("beast", "breast") == token_score("beast", "breast")
|
||||
79
diet_guard/tests/test_gate.py
Normal file
79
diet_guard/tests/test_gate.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Tests for _gate.py — the slot/state composition that decides locking.
|
||||
|
||||
The slot arithmetic and the logged-slot state are both exercised elsewhere, so
|
||||
here the logged set is mocked and ``now`` is injected to drive each decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from python_pkg.diet_guard._gate import due_slots, gate_is_due, gate_message
|
||||
|
||||
|
||||
def _at(hour: int) -> datetime:
|
||||
"""Return a fixed local datetime at ``hour``."""
|
||||
return datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _logged(slots: set[int]) -> object:
|
||||
"""Patch the logged-slots source so the decision is deterministic."""
|
||||
return patch(
|
||||
"python_pkg.diet_guard._gate.logged_slots_today",
|
||||
return_value=slots,
|
||||
)
|
||||
|
||||
|
||||
class TestDueSlots:
|
||||
"""Elapsed-but-unlogged slots."""
|
||||
|
||||
def test_injected_now(self) -> None:
|
||||
"""With 08:00 logged at 13:00, only 12:00 is due."""
|
||||
with _logged({8}):
|
||||
assert due_slots(_at(13)) == (12,)
|
||||
|
||||
def test_default_now_uses_clock(self) -> None:
|
||||
"""Omitting ``now`` reads the real clock (mocked here for determinism)."""
|
||||
with (
|
||||
_logged(set()),
|
||||
patch(
|
||||
"python_pkg.diet_guard._gate.now_local",
|
||||
return_value=_at(9),
|
||||
),
|
||||
):
|
||||
assert due_slots() == (8,)
|
||||
|
||||
|
||||
class TestGateIsDue:
|
||||
"""The boolean lock decision."""
|
||||
|
||||
def test_due_when_a_slot_is_missing(self) -> None:
|
||||
"""A missing elapsed slot warrants a lock."""
|
||||
with _logged(set()):
|
||||
assert gate_is_due(_at(13)) is True
|
||||
|
||||
def test_not_due_when_all_logged(self) -> None:
|
||||
"""Everything elapsed is logged -> no lock."""
|
||||
with _logged({8, 12}):
|
||||
assert gate_is_due(_at(13)) is False
|
||||
|
||||
|
||||
class TestGateMessage:
|
||||
"""The human-readable reason line."""
|
||||
|
||||
def test_all_logged(self) -> None:
|
||||
"""Nothing missing -> the up-to-date message."""
|
||||
with _logged({8, 12}):
|
||||
assert "up to date" in gate_message(_at(13))
|
||||
|
||||
def test_single_missing(self) -> None:
|
||||
"""One missing slot -> singular phrasing."""
|
||||
with _logged({8}):
|
||||
assert gate_message(_at(13)) == "Log your 12:00 meal to unlock."
|
||||
|
||||
def test_multiple_missing(self) -> None:
|
||||
"""Several missing slots -> plural phrasing listing them."""
|
||||
with _logged(set()):
|
||||
message = gate_message(_at(17))
|
||||
assert message == "Log your meals for 08:00, 12:00, 16:00 to unlock."
|
||||
917
diet_guard/tests/test_gatelock.py
Normal file
917
diet_guard/tests/test_gatelock.py
Normal file
@ -0,0 +1,917 @@
|
||||
"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window.
|
||||
|
||||
A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
|
||||
real ``TclError``) replaces the conftest's blanket MagicMock for the duration of
|
||||
each gate test, so the window's *logic* runs for real against in-memory widgets
|
||||
without ever opening a window or grabbing the keyboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.diet_guard import _gatelock
|
||||
from python_pkg.diet_guard._budget import seal_budget
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._gatelock import (
|
||||
MealGate,
|
||||
_format_preview,
|
||||
_pending_slots,
|
||||
_safe_float,
|
||||
acquire_gate_lock,
|
||||
release_gate_lock,
|
||||
wait_for_display,
|
||||
)
|
||||
from python_pkg.diet_guard._meal import MealItem
|
||||
|
||||
# Captured before any autouse fixture patches the module attribute, so the real
|
||||
# class (not the conftest MagicMock) is available for its callback-error test.
|
||||
_REAL_GATE_ROOT = _gatelock._GateRoot
|
||||
|
||||
|
||||
class _FakeTclError(Exception):
|
||||
"""Stand-in for ``tkinter.TclError`` (a real, catchable exception)."""
|
||||
|
||||
|
||||
class FakeVar:
|
||||
"""A functional ``StringVar``: stores and returns a string."""
|
||||
|
||||
def __init__(self, master: object = None, value: str = "") -> None:
|
||||
self._value = value
|
||||
|
||||
def get(self) -> str:
|
||||
return self._value
|
||||
|
||||
def set(self, value: str) -> None:
|
||||
self._value = value
|
||||
|
||||
|
||||
class FakeEntry:
|
||||
"""A functional one-line entry (delete clears, insert appends)."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._value = ""
|
||||
|
||||
def get(self) -> str:
|
||||
return self._value
|
||||
|
||||
def delete(self, first: object, last: object = None) -> None:
|
||||
self._value = ""
|
||||
|
||||
def insert(self, index: object, text: str) -> None:
|
||||
self._value += text
|
||||
|
||||
def pack(self, *args: object, **kwargs: object) -> FakeEntry:
|
||||
return self
|
||||
|
||||
def bind(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def configure(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
config = configure
|
||||
|
||||
def focus_set(self) -> None:
|
||||
pass
|
||||
|
||||
def focus_force(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeText(FakeEntry):
|
||||
"""A functional multi-line text box (``get`` ignores the index range)."""
|
||||
|
||||
def get(self, start: object = None, end: object = None) -> str:
|
||||
return self._value
|
||||
|
||||
|
||||
class FakeListbox:
|
||||
"""A functional listbox tracking items and the current selection."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._items: list[str] = []
|
||||
self._sel: tuple[int, ...] = ()
|
||||
|
||||
def delete(self, first: object, last: object = None) -> None:
|
||||
self._items = []
|
||||
|
||||
def insert(self, index: object, text: str) -> None:
|
||||
self._items.append(text)
|
||||
|
||||
def curselection(self) -> tuple[int, ...]:
|
||||
return self._sel
|
||||
|
||||
def selection_set(self, index: int) -> None:
|
||||
self._sel = (index,)
|
||||
|
||||
def selection_clear(self, first: object, last: object = None) -> None:
|
||||
self._sel = ()
|
||||
|
||||
def pack(self, *args: object, **kwargs: object) -> FakeListbox:
|
||||
return self
|
||||
|
||||
def bind(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeWidget:
|
||||
"""A generic no-op widget for Frame/Label/Button/OptionMenu."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def place(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
config = configure
|
||||
|
||||
def bind(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
_FAKE_TK = SimpleNamespace(
|
||||
END="end",
|
||||
TclError=_FakeTclError,
|
||||
StringVar=FakeVar,
|
||||
Frame=FakeWidget,
|
||||
Label=FakeWidget,
|
||||
Button=FakeWidget,
|
||||
OptionMenu=FakeWidget,
|
||||
Entry=FakeEntry,
|
||||
Text=FakeText,
|
||||
Listbox=FakeListbox,
|
||||
Event=object,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gate() -> object:
|
||||
"""Build a demo gate whose widgets are functional fakes."""
|
||||
with patch.object(_gatelock, "tk", _FAKE_TK):
|
||||
yield MealGate(demo_mode=True)
|
||||
|
||||
|
||||
def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition:
|
||||
"""A simple reference nutrition for driving the form."""
|
||||
return Nutrition(kcal, 10, 20, 5, grams, "food bank")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModuleHelpers:
|
||||
"""Pure functions and the single-instance lock."""
|
||||
|
||||
def test_safe_float_blank(self) -> None:
|
||||
"""A blank string is None."""
|
||||
assert _safe_float("") is None
|
||||
|
||||
def test_safe_float_number(self) -> None:
|
||||
"""A numeric string parses."""
|
||||
assert _safe_float("3.5") == 3.5
|
||||
|
||||
def test_safe_float_non_numeric(self) -> None:
|
||||
"""A non-numeric string is None."""
|
||||
assert _safe_float("abc") is None
|
||||
|
||||
def test_format_preview_with_portion(self) -> None:
|
||||
"""A non-zero portion shows the grams segment."""
|
||||
text = _format_preview(_nutrition(grams=200))
|
||||
assert "200g" in text
|
||||
|
||||
def test_format_preview_without_portion(self) -> None:
|
||||
"""A zero portion omits the grams segment."""
|
||||
text = _format_preview(_nutrition(grams=0))
|
||||
assert "g ·" not in text
|
||||
|
||||
def test_gate_lock_single_instance(self) -> None:
|
||||
"""A second acquire while the first is held returns None."""
|
||||
first = acquire_gate_lock()
|
||||
assert first is not None
|
||||
assert acquire_gate_lock() is None
|
||||
release_gate_lock(first)
|
||||
|
||||
def test_pending_slots_due(self) -> None:
|
||||
"""When slots are due, those are returned verbatim."""
|
||||
with patch.object(_gatelock, "due_slots", return_value=[12, 16]):
|
||||
assert _pending_slots(demo_mode=False) == [12, 16]
|
||||
|
||||
def test_pending_slots_demo_fallback(self) -> None:
|
||||
"""Demo mode invents a representative slot when nothing is due."""
|
||||
with patch.object(_gatelock, "due_slots", return_value=[]):
|
||||
assert len(_pending_slots(demo_mode=True)) == 1
|
||||
|
||||
def test_pending_slots_production_empty(self) -> None:
|
||||
"""Production with nothing due returns no slots."""
|
||||
with patch.object(_gatelock, "due_slots", return_value=[]):
|
||||
assert not _pending_slots(demo_mode=False)
|
||||
|
||||
|
||||
class TestAssertNotUnderPytest:
|
||||
"""The safety net that blocks a real Tk gate under pytest."""
|
||||
|
||||
def test_raises_with_real_tkinter(self) -> None:
|
||||
"""Real tkinter under pytest is refused."""
|
||||
with (
|
||||
patch.object(_gatelock, "tk", SimpleNamespace(__name__="tkinter")),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
_gatelock._assert_not_under_pytest()
|
||||
|
||||
def test_passes_with_mock(self) -> None:
|
||||
"""A mocked tk (name != tkinter) passes straight through."""
|
||||
with patch.object(_gatelock, "tk", SimpleNamespace(__name__="mock")):
|
||||
_gatelock._assert_not_under_pytest()
|
||||
|
||||
|
||||
class TestGateRootCallback:
|
||||
"""The root's callback-exception routing."""
|
||||
|
||||
def test_routes_to_handler(self) -> None:
|
||||
"""A set handler is invoked on a callback error."""
|
||||
root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT)
|
||||
root.on_callback_error = MagicMock()
|
||||
_REAL_GATE_ROOT.report_callback_exception(
|
||||
root, ValueError, ValueError("x"), None
|
||||
)
|
||||
root.on_callback_error.assert_called_once()
|
||||
|
||||
def test_no_handler_is_safe(self) -> None:
|
||||
"""With no handler set, the error is just logged."""
|
||||
root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT)
|
||||
root.on_callback_error = None
|
||||
_REAL_GATE_ROOT.report_callback_exception(
|
||||
root, ValueError, ValueError("x"), None
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Construction
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
"""Building the window in both modes."""
|
||||
|
||||
def test_demo_builds(self, gate: MealGate) -> None:
|
||||
"""A demo gate constructs with a pending slot and grams basis."""
|
||||
assert gate.demo_mode is True
|
||||
assert gate._unit.get() == "grams"
|
||||
|
||||
def test_production_builds(self) -> None:
|
||||
"""A production gate disables VT switching and grabs input."""
|
||||
with (
|
||||
patch.object(_gatelock, "tk", _FAKE_TK),
|
||||
patch.object(_gatelock.shutil, "which", return_value=None),
|
||||
):
|
||||
gate = MealGate(demo_mode=False)
|
||||
assert gate.demo_mode is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Form logic
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormBasics:
|
||||
"""Field helpers and the numeric validator."""
|
||||
|
||||
def test_numeric_validator(self, gate: MealGate) -> None:
|
||||
"""Blank and numbers are allowed; words are not."""
|
||||
assert gate._is_numeric_or_blank("")
|
||||
assert gate._is_numeric_or_blank("12.5")
|
||||
assert not gate._is_numeric_or_blank("abc")
|
||||
|
||||
def test_desc_get_set(self, gate: MealGate) -> None:
|
||||
"""The description round-trips through its helpers, trimmed."""
|
||||
gate._set_desc(" shoarma ")
|
||||
assert gate._get_desc() == "shoarma"
|
||||
|
||||
def test_desc_return_suppresses_newline(self, gate: MealGate) -> None:
|
||||
"""Enter in the description submits and returns the break sentinel."""
|
||||
gate._set_desc("apple")
|
||||
with patch.object(gate, "_on_submit") as submit:
|
||||
assert gate._on_desc_return(None) == "break"
|
||||
submit.assert_called_once()
|
||||
|
||||
def test_macro_values_non_numeric(self, gate: MealGate) -> None:
|
||||
"""A non-numeric macro field makes the whole read None."""
|
||||
gate._kcal_entry.insert(0, "abc")
|
||||
assert gate._macro_values() is None
|
||||
|
||||
|
||||
class TestReferenceModel:
|
||||
"""The reference -> total nutrition computation."""
|
||||
|
||||
def test_reference_none_without_calories(self, gate: MealGate) -> None:
|
||||
"""No calories typed means no reference yet."""
|
||||
assert gate._reference_nutrition() is None
|
||||
|
||||
def test_current_is_reference_without_amount(self, gate: MealGate) -> None:
|
||||
"""With calories but no amount, the reference stands in as the total."""
|
||||
gate._kcal_entry.insert(0, "200")
|
||||
current = gate._current_nutrition()
|
||||
assert current is not None
|
||||
assert current.kcal == 200
|
||||
|
||||
def test_current_scales_with_amount(self, gate: MealGate) -> None:
|
||||
"""Grams eaten scale the per-100 g reference into the total."""
|
||||
gate._kcal_entry.insert(0, "200")
|
||||
gate._amount_entry.insert(0, "200")
|
||||
current = gate._current_nutrition()
|
||||
assert current is not None
|
||||
assert current.kcal == 400
|
||||
|
||||
|
||||
class TestSuggestions:
|
||||
"""Autocomplete population and selection."""
|
||||
|
||||
def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None:
|
||||
"""In items mode, typing a staple fills the per-item weight."""
|
||||
gate._unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
gate._on_desc_keyrelease(None)
|
||||
assert gate._per_entry.get() == "182"
|
||||
|
||||
def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None:
|
||||
"""Picking a banked suggestion adopts its name and macros."""
|
||||
gate._suggestions = [("apple pie", _nutrition(300, 120))]
|
||||
gate._suggestion_mode = "bank"
|
||||
gate._suggestion_box.selection_set(0)
|
||||
gate._on_suggestion_select(None)
|
||||
assert gate._get_desc() == "apple pie"
|
||||
assert gate._kcal_entry.get() == "300"
|
||||
|
||||
def test_select_candidate_keeps_description(self, gate: MealGate) -> None:
|
||||
"""An OFF candidate fills macros but not the typed description."""
|
||||
gate._set_desc("my dish")
|
||||
gate._suggestions = [("openfoodfacts: X", _nutrition(250, 100))]
|
||||
gate._suggestion_mode = "candidates"
|
||||
gate._suggestion_box.selection_set(0)
|
||||
gate._on_suggestion_select(None)
|
||||
assert gate._get_desc() == "my dish"
|
||||
|
||||
def test_select_no_selection(self, gate: MealGate) -> None:
|
||||
"""No selection is a no-op."""
|
||||
gate._on_suggestion_select(None)
|
||||
|
||||
def test_select_out_of_range(self, gate: MealGate) -> None:
|
||||
"""A stale selection index beyond the list is ignored."""
|
||||
gate._suggestions = []
|
||||
gate._suggestion_box.selection_set(5)
|
||||
gate._on_suggestion_select(None)
|
||||
|
||||
|
||||
class TestUnitToggle:
|
||||
"""Switching the grams/items basis."""
|
||||
|
||||
def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None:
|
||||
"""A picked food is re-expressed per item, then back per 100 g."""
|
||||
gate._apply_reference(_nutrition(52, 100), name="apple")
|
||||
gate._unit.set("items")
|
||||
gate._on_unit_change("items")
|
||||
per_item = gate._kcal_entry.get()
|
||||
gate._unit.set("grams")
|
||||
gate._on_unit_change("grams")
|
||||
assert gate._kcal_entry.get() == "52"
|
||||
assert per_item != "52"
|
||||
|
||||
def test_toggle_without_reference_clears(self, gate: MealGate) -> None:
|
||||
"""With no picked food, a toggle clears the macro fields."""
|
||||
gate._kcal_entry.insert(0, "123")
|
||||
gate._last_reference = None
|
||||
gate._unit.set("items")
|
||||
gate._on_unit_change("items")
|
||||
assert gate._kcal_entry.get() == ""
|
||||
|
||||
def test_macro_edit_drops_reference(self, gate: MealGate) -> None:
|
||||
"""Hand-editing a macro invalidates the stored reference."""
|
||||
gate._last_reference = _nutrition()
|
||||
gate._on_macro_edit(None)
|
||||
assert gate._last_reference is None
|
||||
|
||||
|
||||
class TestSubmit:
|
||||
"""The two-step submit (look up, then log)."""
|
||||
|
||||
def test_empty_description(self, gate: MealGate) -> None:
|
||||
"""Submitting with no description prompts for one."""
|
||||
gate._on_submit()
|
||||
assert "Type what you ate" in gate._status.get()
|
||||
|
||||
def test_non_numeric_macros(self, gate: MealGate) -> None:
|
||||
"""Non-numeric macros are rejected before logging."""
|
||||
gate._set_desc("apple")
|
||||
gate._kcal_entry.insert(0, "abc")
|
||||
gate._on_submit()
|
||||
assert "must be numbers" in gate._status.get()
|
||||
|
||||
def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None:
|
||||
"""A blank calorie field looks the food up rather than logging."""
|
||||
gate._set_desc("apple")
|
||||
with patch.object(gate, "_begin_lookup") as lookup:
|
||||
gate._on_submit()
|
||||
lookup.assert_called_once()
|
||||
|
||||
def test_defensive_none_nutrition(self, gate: MealGate) -> None:
|
||||
"""A calorie value but unresolvable nutrition prompts again (guard)."""
|
||||
gate._set_desc("apple")
|
||||
gate._kcal_entry.insert(0, "200")
|
||||
with patch.object(gate, "_current_nutrition", return_value=None):
|
||||
gate._on_submit()
|
||||
assert "Enter the calories" in gate._status.get()
|
||||
|
||||
def test_valid_submit_records(self, gate: MealGate) -> None:
|
||||
"""A described, priced meal is recorded."""
|
||||
gate._set_desc("apple")
|
||||
gate._kcal_entry.insert(0, "95")
|
||||
with patch.object(gate, "_record") as record:
|
||||
gate._on_submit()
|
||||
record.assert_called_once()
|
||||
|
||||
def test_on_return_submits(self, gate: MealGate) -> None:
|
||||
"""Enter in a numeric field submits."""
|
||||
with patch.object(gate, "_on_submit") as submit:
|
||||
gate._on_return(None)
|
||||
submit.assert_called_once()
|
||||
|
||||
|
||||
class TestLookup:
|
||||
"""Step one: filling the form from a lookup."""
|
||||
|
||||
def test_no_candidates(self, gate: MealGate) -> None:
|
||||
"""No match asks for a manual value."""
|
||||
gate._set_desc("nonsense")
|
||||
with patch.object(_gatelock, "lookup_candidates", return_value=[]):
|
||||
gate._begin_lookup("nonsense")
|
||||
assert "Couldn't look that up" in gate._status.get()
|
||||
|
||||
def test_single_candidate(self, gate: MealGate) -> None:
|
||||
"""A single match fills the fields and invites review."""
|
||||
with patch.object(
|
||||
_gatelock,
|
||||
"lookup_candidates",
|
||||
return_value=[("apple", _nutrition(95, 100))],
|
||||
):
|
||||
gate._begin_lookup("apple")
|
||||
assert "Review the values" in gate._status.get()
|
||||
|
||||
def test_multiple_candidates(self, gate: MealGate) -> None:
|
||||
"""Several matches invite picking another."""
|
||||
with patch.object(
|
||||
_gatelock,
|
||||
"lookup_candidates",
|
||||
return_value=[
|
||||
("a", _nutrition(95, 100)),
|
||||
("b", _nutrition(120, 100)),
|
||||
],
|
||||
):
|
||||
gate._begin_lookup("apple")
|
||||
assert "pick another" in gate._status.get()
|
||||
|
||||
|
||||
class TestRecord:
|
||||
"""Logging a meal and advancing the slot walk."""
|
||||
|
||||
def test_demo_logs_without_slot(self, gate: MealGate) -> None:
|
||||
"""A demo record banks the food but tags no real slot."""
|
||||
gate._pending = [8]
|
||||
with patch.object(_gatelock, "log_meal") as log:
|
||||
gate._record("apple", _nutrition(95, 100))
|
||||
assert log.call_args.args[2] is None
|
||||
|
||||
def test_last_slot_unlocks(self, gate: MealGate) -> None:
|
||||
"""Recording the final pending slot triggers the unlock."""
|
||||
gate._pending = [8]
|
||||
with (
|
||||
patch.object(_gatelock, "log_meal"),
|
||||
patch.object(_gatelock, "remember_food"),
|
||||
patch.object(gate, "_unlock") as unlock,
|
||||
):
|
||||
gate._record("apple", _nutrition(95, 100))
|
||||
unlock.assert_called_once()
|
||||
|
||||
def test_more_slots_continue(self, gate: MealGate) -> None:
|
||||
"""With slots remaining, the form clears and prompts the next."""
|
||||
gate._pending = [8, 12]
|
||||
with (
|
||||
patch.object(_gatelock, "log_meal"),
|
||||
patch.object(_gatelock, "remember_food"),
|
||||
):
|
||||
gate._record("apple", _nutrition(95, 100))
|
||||
assert gate._pending == [12]
|
||||
assert "next meal" in gate._status.get()
|
||||
|
||||
def test_unlock_schedules_close(self, gate: MealGate) -> None:
|
||||
"""Unlock sets the closing status and schedules teardown."""
|
||||
gate._unlock("logged X")
|
||||
assert "unlocking" in gate._status.get()
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
"""The running calorie/macro panel."""
|
||||
|
||||
def test_headline_with_budget(self, gate: MealGate) -> None:
|
||||
"""A sealed budget shows consumed/target/remaining."""
|
||||
seal_budget(2000)
|
||||
gate._refresh_dashboard()
|
||||
assert "left" in gate._cal_headline.get()
|
||||
|
||||
def test_headline_without_budget(self, gate: MealGate) -> None:
|
||||
"""With no budget, only today's total is shown."""
|
||||
gate._refresh_dashboard()
|
||||
assert "kcal today" in gate._cal_headline.get()
|
||||
|
||||
def test_dashboard_lists_entries(self, gate: MealGate) -> None:
|
||||
"""Logged entries appear in the detail panel."""
|
||||
seal_budget(2000, weight_kg=80)
|
||||
_gatelock.log_meal("apple", _nutrition(95, 100), 8)
|
||||
gate._refresh_dashboard()
|
||||
text = gate._dashboard.get()
|
||||
assert "apple" in text
|
||||
assert "protein" in text
|
||||
|
||||
def test_dashboard_empty(self, gate: MealGate) -> None:
|
||||
"""With nothing logged, the panel says so."""
|
||||
gate._refresh_dashboard()
|
||||
assert "nothing logged yet" in gate._dashboard.get()
|
||||
|
||||
def test_slot_header_variants(self, gate: MealGate) -> None:
|
||||
"""The header covers none / one / several pending slots."""
|
||||
gate._pending = []
|
||||
gate._refresh_slot_header()
|
||||
assert "All meals logged" in gate._slot_header.get()
|
||||
gate._pending = [8]
|
||||
gate._refresh_slot_header()
|
||||
assert "Log your" in gate._slot_header.get()
|
||||
gate._pending = [8, 12]
|
||||
gate._refresh_slot_header()
|
||||
assert "remaining" in gate._slot_header.get()
|
||||
|
||||
def test_projection_with_budget(self, gate: MealGate) -> None:
|
||||
"""The projection shows the after-this-item remaining when priced."""
|
||||
seal_budget(2000)
|
||||
gate._kcal_entry.insert(0, "300")
|
||||
gate._refresh_projection()
|
||||
assert "after this item" in gate._projection.get()
|
||||
|
||||
|
||||
class TestWindowMechanics:
|
||||
"""VT switching, grabbing, signals, and teardown."""
|
||||
|
||||
def test_disable_vt_no_tool(self, gate: MealGate) -> None:
|
||||
"""A missing setxkbmap leaves VT switching enabled."""
|
||||
with patch.object(_gatelock.shutil, "which", return_value=None):
|
||||
gate._disable_vt_switching()
|
||||
assert gate._vt_disabled is False
|
||||
|
||||
def test_disable_and_restore_vt(self, gate: MealGate) -> None:
|
||||
"""With the tool present, VT switching toggles off then back on."""
|
||||
with (
|
||||
patch.object(_gatelock.shutil, "which", return_value="/x/setxkbmap"),
|
||||
patch.object(_gatelock.subprocess, "run") as run,
|
||||
):
|
||||
gate._disable_vt_switching()
|
||||
assert gate._vt_disabled is True
|
||||
gate._restore_vt_switching()
|
||||
assert gate._vt_disabled is False
|
||||
assert run.call_count == 2
|
||||
|
||||
def test_restore_when_not_disabled(self, gate: MealGate) -> None:
|
||||
"""Restoring when never disabled is a no-op."""
|
||||
gate._vt_disabled = False
|
||||
gate._restore_vt_switching()
|
||||
|
||||
def test_grab_success(self, gate: MealGate) -> None:
|
||||
"""A successful grab focuses the first field."""
|
||||
gate.root.grab_set_global = MagicMock()
|
||||
gate._acquire_global_grab(attempt=1)
|
||||
|
||||
def test_grab_retries_on_conflict(self, gate: MealGate) -> None:
|
||||
"""A held grab reschedules another attempt instead of giving up."""
|
||||
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
|
||||
gate.root.after = MagicMock()
|
||||
gate._acquire_global_grab(attempt=_gatelock._GRAB_LOG_EVERY)
|
||||
gate.root.after.assert_called_once()
|
||||
|
||||
def test_focus_first_field(self, gate: MealGate) -> None:
|
||||
"""Focusing the first field is safe."""
|
||||
gate._focus_first_field()
|
||||
|
||||
def test_keepalive_rearms(self, gate: MealGate) -> None:
|
||||
"""The keepalive reschedules itself."""
|
||||
gate.root.after = MagicMock()
|
||||
gate._keepalive()
|
||||
gate.root.after.assert_called_once()
|
||||
|
||||
def test_signal_restores_and_exits(self, gate: MealGate) -> None:
|
||||
"""A termination signal restores VT switching and exits."""
|
||||
with pytest.raises(SystemExit):
|
||||
gate._on_signal(15, None)
|
||||
|
||||
def test_run_installs_and_loops(self, gate: MealGate) -> None:
|
||||
"""run wires handlers, starts the loop, and restores on exit."""
|
||||
gate.root.mainloop = MagicMock()
|
||||
with (
|
||||
patch.object(_gatelock.signal, "signal"),
|
||||
patch.object(_gatelock.atexit, "register"),
|
||||
):
|
||||
gate.run()
|
||||
gate.root.mainloop.assert_called_once()
|
||||
|
||||
def test_close(self, gate: MealGate) -> None:
|
||||
"""Close restores VT switching and destroys the window."""
|
||||
gate.root.destroy = MagicMock()
|
||||
gate.close()
|
||||
gate.root.destroy.assert_called_once()
|
||||
|
||||
def test_callback_error_status(self, gate: MealGate) -> None:
|
||||
"""An unexpected callback error surfaces a recoverable message."""
|
||||
gate._handle_callback_error()
|
||||
assert "went wrong" in gate._status.get()
|
||||
|
||||
def test_restore_vt_without_tool(self, gate: MealGate) -> None:
|
||||
"""Restoring when the tool has since vanished still clears the flag."""
|
||||
gate._vt_disabled = True
|
||||
with patch.object(_gatelock.shutil, "which", return_value=None):
|
||||
gate._restore_vt_switching()
|
||||
assert gate._vt_disabled is False
|
||||
|
||||
def test_grab_retry_without_log(self, gate: MealGate) -> None:
|
||||
"""An early blocked attempt reschedules without logging."""
|
||||
gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError)
|
||||
gate.root.after = MagicMock()
|
||||
gate._acquire_global_grab(attempt=1)
|
||||
gate.root.after.assert_called_once()
|
||||
|
||||
|
||||
class TestBasisAndAmount:
|
||||
"""Edge branches in the grams/items basis and amount maths."""
|
||||
|
||||
def test_basis_typed_value(self, gate: MealGate) -> None:
|
||||
"""A typed per-value is honoured directly."""
|
||||
gate._set_entry(gate._per_entry, "50")
|
||||
assert gate._basis_grams() == 50
|
||||
|
||||
def test_basis_items_known_staple(self, gate: MealGate) -> None:
|
||||
"""Items mode with a blank per falls back to the staple weight."""
|
||||
gate._per_entry.delete(0)
|
||||
gate._unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
assert gate._basis_grams() == 182
|
||||
|
||||
def test_basis_items_unknown(self, gate: MealGate) -> None:
|
||||
"""An unknown item uses the default piece weight."""
|
||||
gate._per_entry.delete(0)
|
||||
gate._unit.set("items")
|
||||
gate._set_desc("mystery")
|
||||
assert gate._basis_grams() == _gatelock.DEFAULT_ITEM_GRAMS
|
||||
|
||||
def test_basis_grams_default(self, gate: MealGate) -> None:
|
||||
"""Grams mode with a blank per uses the per-100 g default."""
|
||||
gate._per_entry.delete(0)
|
||||
assert gate._basis_grams() == _gatelock._DEFAULT_PER_GRAMS
|
||||
|
||||
def test_eaten_grams_none(self, gate: MealGate) -> None:
|
||||
"""No amount typed yields no eaten weight."""
|
||||
assert gate._eaten_grams() is None
|
||||
|
||||
def test_eaten_grams_items(self, gate: MealGate) -> None:
|
||||
"""Items mode multiplies the count by the per-item weight."""
|
||||
gate._unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
gate._set_entry(gate._per_entry, "182")
|
||||
gate._set_entry(gate._amount_entry, "5")
|
||||
assert gate._eaten_grams() == 5 * 182
|
||||
|
||||
def test_amount_change_refreshes(self, gate: MealGate) -> None:
|
||||
"""Changing the amount recomputes the preview."""
|
||||
gate._set_entry(gate._kcal_entry, "100")
|
||||
gate._set_entry(gate._amount_entry, "200")
|
||||
gate._on_amount_change(None)
|
||||
assert gate._preview.get()
|
||||
|
||||
def test_projection_else_without_item(self, gate: MealGate) -> None:
|
||||
"""With a budget but no priced item, no after-this-item is shown."""
|
||||
seal_budget(2000)
|
||||
gate._refresh_projection()
|
||||
text = gate._projection.get()
|
||||
assert "left" in text
|
||||
assert "after this item" not in text
|
||||
|
||||
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
|
||||
"""In grams mode the per-item weight is not touched on keyrelease."""
|
||||
gate._unit.set("grams")
|
||||
gate._set_desc("apple")
|
||||
gate._on_desc_keyrelease(None)
|
||||
|
||||
def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
|
||||
"""An unknown item in items mode leaves the per field unchanged."""
|
||||
gate._unit.set("items")
|
||||
gate._set_desc("zzzz")
|
||||
gate._on_desc_keyrelease(None)
|
||||
|
||||
def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
|
||||
"""A grams-mode pick does not overwrite an amount already typed."""
|
||||
gate._set_entry(gate._amount_entry, "50")
|
||||
gate._apply_reference(_nutrition(100, 100))
|
||||
assert gate._amount_entry.get() == "50"
|
||||
|
||||
|
||||
class TestDisplayReadiness:
|
||||
"""The session-start display wait that absorbs the X auth-cookie race."""
|
||||
|
||||
def test_ready_when_root_connects(self) -> None:
|
||||
"""A Tk root that builds and destroys cleanly means the display is up."""
|
||||
fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError)
|
||||
with patch.object(_gatelock, "tk", fake_tk):
|
||||
assert _gatelock._display_is_ready() is True
|
||||
fake_tk.Tk.return_value.destroy.assert_called_once()
|
||||
|
||||
def test_not_ready_on_tclerror(self) -> None:
|
||||
"""A TclError from Tk() (no display / no cookie yet) means not ready."""
|
||||
fake_tk = SimpleNamespace(
|
||||
Tk=MagicMock(side_effect=_FakeTclError("no display")),
|
||||
TclError=_FakeTclError,
|
||||
)
|
||||
with patch.object(_gatelock, "tk", fake_tk):
|
||||
assert _gatelock._display_is_ready() is False
|
||||
|
||||
def test_wait_returns_immediately_when_ready(self) -> None:
|
||||
"""A display ready on the first probe returns at once and never sleeps."""
|
||||
sleep = MagicMock()
|
||||
with patch.object(_gatelock, "_display_is_ready", return_value=True):
|
||||
ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0))
|
||||
assert ready is True
|
||||
sleep.assert_not_called()
|
||||
|
||||
def test_wait_polls_then_succeeds(self) -> None:
|
||||
"""Not-ready then ready sleeps once between probes, then unblocks."""
|
||||
sleep = MagicMock()
|
||||
monotonic = MagicMock(side_effect=[0.0, 0.0])
|
||||
with patch.object(_gatelock, "_display_is_ready", side_effect=[False, True]):
|
||||
assert wait_for_display(sleep=sleep, monotonic=monotonic) is True
|
||||
sleep.assert_called_once()
|
||||
|
||||
def test_wait_times_out_and_defers(self) -> None:
|
||||
"""A display still down at the deadline gives up so the next tick retries."""
|
||||
sleep = MagicMock()
|
||||
monotonic = MagicMock(side_effect=[0.0, 60.0])
|
||||
with patch.object(_gatelock, "_display_is_ready", return_value=False):
|
||||
assert wait_for_display(sleep=sleep, monotonic=monotonic) is False
|
||||
sleep.assert_not_called()
|
||||
|
||||
|
||||
class TestMealFlow:
|
||||
"""Building and logging a multi-item composite meal."""
|
||||
|
||||
def test_meal_name_trimmed(self, gate: MealGate) -> None:
|
||||
"""The meal name is read back trimmed."""
|
||||
gate._meal_name_entry.insert(0, " dinner ")
|
||||
assert gate._meal_name() == "dinner"
|
||||
|
||||
def test_summary_empty_with_no_items(self, gate: MealGate) -> None:
|
||||
"""With no accumulated items the running summary is blank."""
|
||||
gate._refresh_meal_summary()
|
||||
assert gate._meal_summary.get() == ""
|
||||
|
||||
def test_summary_lists_items_and_total(self, gate: MealGate) -> None:
|
||||
"""The summary shows the item names and the running calorie total."""
|
||||
gate._meal_items = [
|
||||
MealItem("salad", _nutrition(80, 120)),
|
||||
MealItem("chicken", _nutrition(330, 200)),
|
||||
]
|
||||
gate._refresh_meal_summary()
|
||||
summary = gate._meal_summary.get()
|
||||
assert "salad, chicken" in summary
|
||||
assert "410 kcal" in summary
|
||||
|
||||
def test_add_item_requires_description(self, gate: MealGate) -> None:
|
||||
"""Adding with no description prompts for one."""
|
||||
gate._on_add_item()
|
||||
assert "Type the item first" in gate._status.get()
|
||||
|
||||
def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None:
|
||||
"""Non-numeric macros are rejected before adding."""
|
||||
gate._set_desc("salad")
|
||||
gate._kcal_entry.insert(0, "abc")
|
||||
gate._on_add_item()
|
||||
assert "must be numbers" in gate._status.get()
|
||||
|
||||
def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None:
|
||||
"""A blank calorie field looks the item up rather than adding."""
|
||||
gate._set_desc("salad")
|
||||
with patch.object(gate, "_begin_lookup") as lookup:
|
||||
gate._on_add_item()
|
||||
lookup.assert_called_once()
|
||||
|
||||
def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None:
|
||||
"""A priced item that will not resolve prompts again (guard)."""
|
||||
gate._set_desc("salad")
|
||||
gate._kcal_entry.insert(0, "80")
|
||||
with patch.object(gate, "_current_nutrition", return_value=None):
|
||||
gate._on_add_item()
|
||||
assert "add the item" in gate._status.get()
|
||||
|
||||
def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None:
|
||||
"""A valid item is appended, the form clears, the meal name is kept."""
|
||||
gate._meal_name_entry.insert(0, "dinner")
|
||||
gate._set_desc("salad")
|
||||
gate._kcal_entry.insert(0, "80")
|
||||
gate._on_add_item()
|
||||
assert len(gate._meal_items) == 1
|
||||
assert gate._meal_items[0].name == "salad"
|
||||
assert gate._get_desc() == ""
|
||||
assert gate._meal_name() == "dinner"
|
||||
assert "Added salad" in gate._status.get()
|
||||
|
||||
def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None:
|
||||
"""Submitting an empty form with items finalizes the meal."""
|
||||
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
|
||||
with patch.object(gate, "_log_meal") as log_meal_:
|
||||
gate._on_submit()
|
||||
log_meal_.assert_called_once()
|
||||
|
||||
def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None:
|
||||
"""A filled form plus existing items adds the form item, then logs."""
|
||||
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
|
||||
gate._set_desc("rice")
|
||||
gate._kcal_entry.insert(0, "260")
|
||||
with patch.object(gate, "_log_meal") as log_meal_:
|
||||
gate._on_submit()
|
||||
assert len(gate._meal_items) == 2
|
||||
assert gate._meal_items[1].name == "rice"
|
||||
log_meal_.assert_called_once()
|
||||
|
||||
def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None:
|
||||
"""Logging a meal banks it under the typed name and advances the slot."""
|
||||
gate._pending = [8, 12]
|
||||
gate._meal_name_entry.insert(0, "dinner")
|
||||
gate._meal_items = [
|
||||
MealItem("salad", _nutrition(80, 120)),
|
||||
MealItem("chicken", _nutrition(330, 200)),
|
||||
]
|
||||
with (
|
||||
patch.object(
|
||||
_gatelock, "remember_meal", return_value=_nutrition(410, 320)
|
||||
) as remember,
|
||||
patch.object(_gatelock, "log_meal") as log,
|
||||
):
|
||||
gate._log_meal()
|
||||
assert remember.call_args.args[0] == "dinner"
|
||||
assert log.call_args.args[0] == "dinner"
|
||||
assert gate._meal_items == []
|
||||
assert gate._pending == [12]
|
||||
|
||||
def test_log_meal_uses_default_name(self, gate: MealGate) -> None:
|
||||
"""A blank meal name falls back to the default."""
|
||||
gate._pending = [8, 12]
|
||||
gate._meal_items = [MealItem("soup", _nutrition(150, 300))]
|
||||
with (
|
||||
patch.object(
|
||||
_gatelock, "remember_meal", return_value=_nutrition(150, 300)
|
||||
) as remember,
|
||||
patch.object(_gatelock, "log_meal"),
|
||||
):
|
||||
gate._log_meal()
|
||||
assert remember.call_args.args[0] == _gatelock._DEFAULT_MEAL_NAME
|
||||
|
||||
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
|
||||
"""A demo gate tags logs with no real slot."""
|
||||
gate._pending = [8]
|
||||
assert gate._slot_for_log() is None
|
||||
|
||||
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
|
||||
"""A production gate tags logs with the current slot."""
|
||||
gate.demo_mode = False
|
||||
gate._pending = [12]
|
||||
assert gate._slot_for_log() == 12
|
||||
|
||||
def test_clear_inputs_discards_meal(self, gate: MealGate) -> None:
|
||||
"""Clearing between slots drops the in-progress meal and its name."""
|
||||
gate._meal_items = [MealItem("salad", _nutrition(80, 120))]
|
||||
gate._meal_name_entry.insert(0, "dinner")
|
||||
gate._meal_summary.set("something")
|
||||
gate._clear_inputs()
|
||||
assert gate._meal_items == []
|
||||
assert gate._meal_name() == ""
|
||||
assert gate._meal_summary.get() == ""
|
||||
|
||||
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
|
||||
"""Finishing the final slot triggers unlock."""
|
||||
gate._pending = [20]
|
||||
with patch.object(gate, "_unlock") as unlock:
|
||||
gate._finish_slot("done")
|
||||
unlock.assert_called_once()
|
||||
21
diet_guard/tests/test_main.py
Normal file
21
diet_guard/tests/test_main.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Tests for the package entry points (__init__, __main__).
|
||||
|
||||
Importing ``__main__`` executes its module-level code (the ``if __name__`` guard
|
||||
is excluded from coverage), wiring the ``python -m`` entry point under test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def test_main_module_imports() -> None:
|
||||
"""The ``python -m python_pkg.diet_guard`` entry module imports cleanly."""
|
||||
module = importlib.import_module("python_pkg.diet_guard.__main__")
|
||||
assert hasattr(module, "main")
|
||||
|
||||
|
||||
def test_package_imports() -> None:
|
||||
"""The package itself imports without side effects."""
|
||||
package = importlib.import_module("python_pkg.diet_guard")
|
||||
assert package is not None
|
||||
60
diet_guard/tests/test_meal.py
Normal file
60
diet_guard/tests/test_meal.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Tests for _meal.py — composite-meal summing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._meal import MEAL_SOURCE, MealItem, meal_total
|
||||
|
||||
|
||||
def _item(
|
||||
name: str,
|
||||
kcal: float,
|
||||
macros: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
|
||||
) -> MealItem:
|
||||
"""Build a MealItem from a name, calories, and a (protein, carbs, fat, grams)."""
|
||||
protein, carbs, fat, grams = macros
|
||||
return MealItem(
|
||||
name,
|
||||
Nutrition(
|
||||
kcal=kcal,
|
||||
protein_g=protein,
|
||||
carbs_g=carbs,
|
||||
fat_g=fat,
|
||||
grams=grams,
|
||||
source="manual",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestMealTotal:
|
||||
"""Summing a meal's items."""
|
||||
|
||||
def test_sums_every_field(self) -> None:
|
||||
"""Each macro, calories, and weight are added across the items."""
|
||||
items = [
|
||||
_item("salad", 80, (2, 8, 5, 120)),
|
||||
_item("chicken", 330, (62, 0, 7, 200)),
|
||||
_item("rice", 260, (5, 56, 1, 180)),
|
||||
]
|
||||
total = meal_total(items)
|
||||
assert total.kcal == 670
|
||||
assert total.protein_g == 69
|
||||
assert total.carbs_g == 64
|
||||
assert total.fat_g == 13
|
||||
assert total.grams == 500
|
||||
assert total.source == MEAL_SOURCE
|
||||
|
||||
def test_empty_is_zero(self) -> None:
|
||||
"""An empty meal sums to an all-zero composite rather than raising."""
|
||||
assert meal_total([]) == Nutrition(
|
||||
kcal=0.0,
|
||||
protein_g=0.0,
|
||||
carbs_g=0.0,
|
||||
fat_g=0.0,
|
||||
grams=0.0,
|
||||
source=MEAL_SOURCE,
|
||||
)
|
||||
|
||||
def test_rounds_to_one_decimal(self) -> None:
|
||||
"""Floating sums are rounded to 0.1, like the rest of the log."""
|
||||
assert meal_total([_item("a", 0.1), _item("b", 0.2)]).kcal == 0.3
|
||||
65
diet_guard/tests/test_portions.py
Normal file
65
diet_guard/tests/test_portions.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""Tests for _portions.py — built-in staple weights and macros.
|
||||
|
||||
Covers the fuzzy staple match, the empty-input guard, and the per-100 g
|
||||
Nutrition / suggestion builders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_pkg.diet_guard._portions import (
|
||||
estimate_unit_grams,
|
||||
staple_nutrition,
|
||||
suggest_staples,
|
||||
)
|
||||
|
||||
|
||||
class TestEstimateUnitGrams:
|
||||
"""One piece's typical weight."""
|
||||
|
||||
def test_known_staple(self) -> None:
|
||||
"""A known staple returns its curated unit weight."""
|
||||
assert estimate_unit_grams("apple") == 182.0
|
||||
|
||||
def test_fuzzy_plural(self) -> None:
|
||||
"""A close variant (plural) still matches the staple."""
|
||||
assert estimate_unit_grams("apples") == 182.0
|
||||
|
||||
def test_unknown_returns_none(self) -> None:
|
||||
"""An unrecognised food has no known unit weight."""
|
||||
assert estimate_unit_grams("quinoa risotto") is None
|
||||
|
||||
def test_empty_returns_none(self) -> None:
|
||||
"""A blank description short-circuits to None."""
|
||||
assert estimate_unit_grams(" ") is None
|
||||
|
||||
|
||||
class TestStapleNutrition:
|
||||
"""Per-100 g Nutrition for a staple."""
|
||||
|
||||
def test_known_staple_per_100g(self) -> None:
|
||||
"""An egg resolves to its per-100 g macros at a 100 g basis."""
|
||||
nutrition = staple_nutrition("egg")
|
||||
assert nutrition is not None
|
||||
assert nutrition.grams == 100.0
|
||||
assert nutrition.source == "staple: egg"
|
||||
|
||||
def test_unknown_returns_none(self) -> None:
|
||||
"""A non-staple resolves to None."""
|
||||
assert staple_nutrition("beef wellington") is None
|
||||
|
||||
|
||||
class TestSuggestStaples:
|
||||
"""Live autocomplete over the staple table."""
|
||||
|
||||
def test_match(self) -> None:
|
||||
"""A matching query surfaces the staple by name."""
|
||||
names = [name for name, _ in suggest_staples("banana")]
|
||||
assert "banana" in names
|
||||
|
||||
def test_empty_query(self) -> None:
|
||||
"""A blank query suggests nothing."""
|
||||
assert suggest_staples("") == []
|
||||
|
||||
def test_no_match(self) -> None:
|
||||
"""A query matching no staple returns an empty list."""
|
||||
assert suggest_staples("xyzzy") == []
|
||||
141
diet_guard/tests/test_resolve.py
Normal file
141
diet_guard/tests/test_resolve.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Tests for _resolve.py — the manual/bank/staple/OFF resolution precedence.
|
||||
|
||||
Real food-bank and staple lookups are used (both isolated/offline); only the
|
||||
Open Food Facts network layer is mocked, via the estimator it delegates to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._foodbank import remember_food
|
||||
from python_pkg.diet_guard._resolve import (
|
||||
ManualMacros,
|
||||
lookup_candidates,
|
||||
resolve_nutrition,
|
||||
suggest_foods,
|
||||
)
|
||||
|
||||
_OFF = Nutrition(260, 12, 30, 10, 100, "openfoodfacts: Big Mac")
|
||||
|
||||
|
||||
class TestResolveManual:
|
||||
"""A manual calorie value, scaled from its stated basis."""
|
||||
|
||||
def test_per_grams_scaled_to_eaten(self) -> None:
|
||||
"""200 kcal per 100 g eaten as 330 g logs 660 kcal."""
|
||||
result = resolve_nutrition(
|
||||
"pasta",
|
||||
grams=330,
|
||||
manual_macros=ManualMacros(kcal=200, per_grams=100),
|
||||
)
|
||||
assert result is not None
|
||||
assert result.kcal == 660.0
|
||||
|
||||
def test_no_basis_keeps_value(self) -> None:
|
||||
"""With neither grams nor per-grams, the manual value is taken as-is."""
|
||||
result = resolve_nutrition("shake", manual_macros=ManualMacros(kcal=180))
|
||||
assert result is not None
|
||||
assert result.kcal == 180.0
|
||||
|
||||
def test_grams_only_is_the_basis(self) -> None:
|
||||
"""With grams but no per-grams, grams is the reference (no double scale)."""
|
||||
result = resolve_nutrition(
|
||||
"soup",
|
||||
grams=250,
|
||||
manual_macros=ManualMacros(kcal=300),
|
||||
)
|
||||
assert result is not None
|
||||
assert result.kcal == 300.0
|
||||
|
||||
|
||||
class TestResolveBankAndStaple:
|
||||
"""Local sources, before any network call."""
|
||||
|
||||
def test_banked_food_scaled(self) -> None:
|
||||
"""A banked food is rescaled to the amount eaten."""
|
||||
remember_food("carbonara", Nutrition(700, 20, 80, 30, 350, "manual"))
|
||||
result = resolve_nutrition("carbonara", grams=700)
|
||||
assert result is not None
|
||||
assert result.kcal == 1400.0
|
||||
|
||||
def test_banked_food_no_grams(self) -> None:
|
||||
"""Without grams, the banked macros are returned unscaled."""
|
||||
remember_food("carbonara", Nutrition(700, 20, 80, 30, 350, "manual"))
|
||||
result = resolve_nutrition("carbonara")
|
||||
assert result is not None
|
||||
assert result.kcal == 700.0
|
||||
|
||||
def test_staple_before_off(self) -> None:
|
||||
"""A bare staple resolves locally (and never hits OFF)."""
|
||||
result = resolve_nutrition("apple", grams=200)
|
||||
assert result is not None
|
||||
assert "staple: apple" in result.source
|
||||
|
||||
def test_staple_no_grams(self) -> None:
|
||||
"""A staple with no grams returns its per-100 g basis."""
|
||||
result = resolve_nutrition("egg")
|
||||
assert result is not None
|
||||
assert result.grams == 100.0
|
||||
|
||||
def test_off_fallback(self) -> None:
|
||||
"""An unknown, non-staple food falls through to Open Food Facts."""
|
||||
with patch(
|
||||
"python_pkg.diet_guard._resolve.estimate_off",
|
||||
return_value=_OFF,
|
||||
):
|
||||
result = resolve_nutrition("exotic dish")
|
||||
assert result is not None
|
||||
assert "openfoodfacts" in result.source
|
||||
|
||||
|
||||
class TestLookupCandidates:
|
||||
"""Reviewable candidates for the blank-calorie gate path."""
|
||||
|
||||
def test_banked_candidate(self) -> None:
|
||||
"""A banked food yields a single scaled candidate under its name."""
|
||||
remember_food("oats", Nutrition(380, 13, 67, 7, 100, "manual"))
|
||||
candidates = lookup_candidates("oats", grams=200)
|
||||
assert candidates[0][0] == "oats"
|
||||
assert candidates[0][1].kcal == 760.0
|
||||
|
||||
def test_banked_candidate_no_grams(self) -> None:
|
||||
"""Without grams the banked candidate is unscaled."""
|
||||
remember_food("oats", Nutrition(380, 13, 67, 7, 100, "manual"))
|
||||
assert lookup_candidates("oats")[0][1].kcal == 380.0
|
||||
|
||||
def test_staple_candidate(self) -> None:
|
||||
"""A staple yields a candidate labelled by its source."""
|
||||
candidates = lookup_candidates("banana", grams=100)
|
||||
assert "staple: banana" in candidates[0][0]
|
||||
|
||||
def test_staple_candidate_no_grams(self) -> None:
|
||||
"""A staple candidate with no grams stays at its 100 g basis."""
|
||||
assert lookup_candidates("banana")[0][1].grams == 100.0
|
||||
|
||||
def test_off_candidates(self) -> None:
|
||||
"""An unknown food returns the OFF alternatives, labelled by source."""
|
||||
with patch(
|
||||
"python_pkg.diet_guard._resolve.off_candidates",
|
||||
return_value=[_OFF],
|
||||
):
|
||||
candidates = lookup_candidates("exotic dish")
|
||||
assert candidates[0][0] == _OFF.source
|
||||
|
||||
|
||||
class TestSuggestFoods:
|
||||
"""Merged bank + staple autocomplete."""
|
||||
|
||||
def test_bank_ranked_first(self) -> None:
|
||||
"""A banked food appears ahead of staples for the same query."""
|
||||
remember_food("apple pie", Nutrition(300, 3, 50, 12, 120, "manual"))
|
||||
names = [name for name, _ in suggest_foods("apple")]
|
||||
assert names[0] == "apple pie"
|
||||
assert "apple" in names
|
||||
|
||||
def test_staple_not_duplicated(self) -> None:
|
||||
"""A staple already banked under the same name is not duplicated."""
|
||||
remember_food("apple", Nutrition(95, 0, 25, 0, 182, "manual"))
|
||||
names = [name for name, _ in suggest_foods("apple")]
|
||||
assert names.count("apple") == 1
|
||||
111
diet_guard/tests/test_slots.py
Normal file
111
diet_guard/tests/test_slots.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Tests for _slots.py — pure meal-slot arithmetic.
|
||||
|
||||
Every function is a total function of ``now`` and the slot constants, so the
|
||||
time-of-day edges are exercised directly with fixed ``datetime`` values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from python_pkg.diet_guard._slots import (
|
||||
current_slot,
|
||||
day_slots,
|
||||
elapsed_slots,
|
||||
missing_slots,
|
||||
slot_label,
|
||||
within_enforcement_window,
|
||||
)
|
||||
|
||||
|
||||
def _at(hour: int) -> datetime:
|
||||
"""Return a fixed local datetime at ``hour`` (date is irrelevant here)."""
|
||||
return datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class TestDaySlots:
|
||||
"""The fixed slot schedule derived from the constants."""
|
||||
|
||||
def test_default_schedule(self) -> None:
|
||||
"""Slots open every 4h from 08:00 up to (not past) the 22:00 cutoff."""
|
||||
assert day_slots() == (8, 12, 16, 20)
|
||||
|
||||
|
||||
class TestEnforcementWindow:
|
||||
"""The [day_start, eating_end) active window."""
|
||||
|
||||
def test_before_window(self) -> None:
|
||||
"""An hour before the first slot is outside the window."""
|
||||
assert not within_enforcement_window(_at(7))
|
||||
|
||||
def test_first_slot_is_inside(self) -> None:
|
||||
"""The day-start hour is inside (inclusive lower bound)."""
|
||||
assert within_enforcement_window(_at(8))
|
||||
|
||||
def test_last_active_hour_inside(self) -> None:
|
||||
"""21:00 is still inside; the cutoff is exclusive at 22:00."""
|
||||
assert within_enforcement_window(_at(21))
|
||||
|
||||
def test_cutoff_is_outside(self) -> None:
|
||||
"""The cutoff hour itself is outside (exclusive upper bound)."""
|
||||
assert not within_enforcement_window(_at(22))
|
||||
|
||||
|
||||
class TestElapsedSlots:
|
||||
"""Which slots have arrived as of now."""
|
||||
|
||||
def test_empty_before_window(self) -> None:
|
||||
"""Before the first slot, nothing has elapsed."""
|
||||
assert elapsed_slots(_at(7)) == ()
|
||||
|
||||
def test_empty_after_cutoff(self) -> None:
|
||||
"""After the overnight cutoff, slots lapse to empty."""
|
||||
assert elapsed_slots(_at(23)) == ()
|
||||
|
||||
def test_first_slot_only(self) -> None:
|
||||
"""At 08:00 exactly, only the 08:00 slot has elapsed."""
|
||||
assert elapsed_slots(_at(8)) == (8,)
|
||||
|
||||
def test_midday(self) -> None:
|
||||
"""At 13:00, the 08:00 and 12:00 slots have elapsed."""
|
||||
assert elapsed_slots(_at(13)) == (8, 12)
|
||||
|
||||
def test_all_elapsed_late(self) -> None:
|
||||
"""At 21:00, every slot for the day has elapsed."""
|
||||
assert elapsed_slots(_at(21)) == (8, 12, 16, 20)
|
||||
|
||||
|
||||
class TestMissingSlots:
|
||||
"""Elapsed slots not yet satisfied by a logged meal."""
|
||||
|
||||
def test_none_missing_when_all_logged(self) -> None:
|
||||
"""All elapsed slots logged -> nothing due."""
|
||||
assert missing_slots(_at(13), {8, 12}) == ()
|
||||
|
||||
def test_reports_unlogged(self) -> None:
|
||||
"""Only the unlogged elapsed slots are returned, ascending."""
|
||||
assert missing_slots(_at(17), {8}) == (12, 16)
|
||||
|
||||
|
||||
class TestCurrentSlot:
|
||||
"""The most recent elapsed slot (used to tag a CLI ``ate``)."""
|
||||
|
||||
def test_none_before_any_slot(self) -> None:
|
||||
"""Before the first slot there is no current slot."""
|
||||
assert current_slot(_at(7)) is None
|
||||
|
||||
def test_latest_elapsed(self) -> None:
|
||||
"""At 13:00 the current slot is 12:00 (the latest elapsed)."""
|
||||
assert current_slot(_at(13)) == 12
|
||||
|
||||
|
||||
class TestSlotLabel:
|
||||
"""Human HH:00 labels."""
|
||||
|
||||
def test_morning_zero_padded(self) -> None:
|
||||
"""A single-digit hour is zero-padded."""
|
||||
assert slot_label(8) == "08:00"
|
||||
|
||||
def test_evening(self) -> None:
|
||||
"""A two-digit hour formats plainly."""
|
||||
assert slot_label(20) == "20:00"
|
||||
248
diet_guard/tests/test_state.py
Normal file
248
diet_guard/tests/test_state.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""Tests for _state.py — the HMAC-signed daily food log.
|
||||
|
||||
State files are redirected into ``tmp_path`` and a deterministic HMAC key is
|
||||
provided by the autouse conftest fixtures, so signing, verification, and the
|
||||
defensive read paths are all exercised in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.diet_guard import _state
|
||||
from python_pkg.diet_guard._budget import BudgetNotInitializedError, seal_budget
|
||||
from python_pkg.diet_guard._estimator import Nutrition
|
||||
from python_pkg.diet_guard._state import (
|
||||
consumption_band,
|
||||
entry_kcal,
|
||||
load_log,
|
||||
log_meal,
|
||||
logged_slots_today,
|
||||
now_local,
|
||||
remaining_budget,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
undo_last_today,
|
||||
)
|
||||
|
||||
|
||||
def _nut(
|
||||
kcal: float, *, protein: float = 0, carbs: float = 0, fat: float = 0
|
||||
) -> Nutrition:
|
||||
"""Build a Nutrition for a logged meal."""
|
||||
return Nutrition(kcal, protein, carbs, fat, 100, "manual")
|
||||
|
||||
|
||||
def _raw() -> dict[str, list[dict[str, object]]]:
|
||||
"""Read the raw log file as parsed JSON (no verification)."""
|
||||
return json.loads(_state.FOOD_LOG_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestClock:
|
||||
"""Time helpers."""
|
||||
|
||||
def test_now_local_is_aware(self) -> None:
|
||||
"""now_local returns a timezone-aware datetime."""
|
||||
assert now_local().tzinfo is not None
|
||||
|
||||
|
||||
class TestEntryFloat:
|
||||
"""Numeric field coercion."""
|
||||
|
||||
def test_missing_is_zero(self) -> None:
|
||||
"""An absent field reads as 0.0."""
|
||||
assert entry_kcal({}) == 0.0
|
||||
|
||||
def test_bool_is_zero(self) -> None:
|
||||
"""A bool calorie value is rejected as 0.0."""
|
||||
assert _state._entry_float({"kcal": True}, "kcal") == 0.0
|
||||
|
||||
def test_number_passes(self) -> None:
|
||||
"""A real number is returned as a float."""
|
||||
assert entry_kcal({"kcal": 321}) == 321.0
|
||||
|
||||
def test_non_numeric_is_zero(self) -> None:
|
||||
"""A non-numeric field reads as 0.0."""
|
||||
assert _state._entry_float({"kcal": "lots"}, "kcal") == 0.0
|
||||
|
||||
|
||||
class TestLogAndTotals:
|
||||
"""Logging meals and aggregating the day."""
|
||||
|
||||
def test_log_and_total(self) -> None:
|
||||
"""A logged meal counts toward the day's calories."""
|
||||
log_meal("toast", _nut(150), slot=8)
|
||||
assert today_total_kcal() == 150.0
|
||||
|
||||
def test_entry_carries_signature(self) -> None:
|
||||
"""With a key present, the stored entry is signed."""
|
||||
entry = log_meal("toast", _nut(150), slot=8)
|
||||
assert "hmac" in entry
|
||||
|
||||
def test_unsigned_when_no_key(self) -> None:
|
||||
"""With no key, the entry is written unsigned and still read back."""
|
||||
with patch.object(_state, "compute_entry_hmac", return_value=None):
|
||||
log_meal("toast", _nut(150), slot=8)
|
||||
assert "hmac" not in _raw()[next(iter(_raw()))][0]
|
||||
assert today_total_kcal() == 150.0
|
||||
|
||||
def test_macros_sum(self) -> None:
|
||||
"""today_total_macros sums protein/carbs/fat across entries."""
|
||||
log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=8)
|
||||
log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=12)
|
||||
assert today_total_macros() == (16.0, 45.0, 11.0)
|
||||
|
||||
def test_slotless_entry_counts_calories_only(self) -> None:
|
||||
"""An entry logged with no slot adds calories but satisfies no slot."""
|
||||
log_meal("snack", _nut(99))
|
||||
assert today_total_kcal() == 99.0
|
||||
assert logged_slots_today() == set()
|
||||
|
||||
|
||||
class TestLoggedSlots:
|
||||
"""Which slots today's log has satisfied."""
|
||||
|
||||
def test_int_slots_counted(self) -> None:
|
||||
"""Integer slot tags are reported."""
|
||||
log_meal("a", _nut(1), slot=8)
|
||||
log_meal("b", _nut(1), slot=12)
|
||||
assert logged_slots_today() == {8, 12}
|
||||
|
||||
def test_bool_slot_excluded(self) -> None:
|
||||
"""A bool masquerading as a slot is ignored."""
|
||||
log_meal("a", _nut(1), slot=8)
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
raw[day].append({"kcal": 1, "slot": True})
|
||||
_state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8")
|
||||
assert logged_slots_today() == {8}
|
||||
|
||||
|
||||
class TestReadDefensive:
|
||||
"""The raw read tolerates missing/corrupt/mis-shaped files."""
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""No file -> empty log."""
|
||||
assert _state._read_raw_log() == {}
|
||||
|
||||
def test_corrupt_json(self) -> None:
|
||||
"""Unparsable content -> empty log."""
|
||||
_state.FOOD_LOG_FILE.write_text("nope", encoding="utf-8")
|
||||
assert _state._read_raw_log() == {}
|
||||
|
||||
def test_top_level_not_dict(self) -> None:
|
||||
"""A non-object top level -> empty log."""
|
||||
_state.FOOD_LOG_FILE.write_text("[1,2]", encoding="utf-8")
|
||||
assert _state._read_raw_log() == {}
|
||||
|
||||
def test_filters_non_list_and_non_dict(self) -> None:
|
||||
"""Non-list day values are dropped; non-dict entries are filtered out."""
|
||||
_state.FOOD_LOG_FILE.write_text(
|
||||
json.dumps({"2026-06-08": [{"kcal": 1}, 99], "junk": "notalist"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _state._read_raw_log()
|
||||
assert result == {"2026-06-08": [{"kcal": 1}]}
|
||||
|
||||
|
||||
class TestVerification:
|
||||
"""Tamper detection on read via the shared HMAC key."""
|
||||
|
||||
def test_valid_entry_kept(self) -> None:
|
||||
"""A correctly signed entry survives verification."""
|
||||
log_meal("toast", _nut(150), slot=8)
|
||||
assert today_entries()
|
||||
|
||||
def test_tampered_entry_dropped(self) -> None:
|
||||
"""An edited calorie value invalidates the signature and is dropped."""
|
||||
log_meal("toast", _nut(150), slot=8)
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
raw[day][0]["kcal"] = 999
|
||||
_state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8")
|
||||
assert today_entries() == []
|
||||
|
||||
def test_unsigned_rejected_when_key_present(self) -> None:
|
||||
"""An entry with no signature is rejected while a key exists."""
|
||||
_state.FOOD_LOG_FILE.write_text(
|
||||
json.dumps({_state._today(): [{"kcal": 1}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert today_entries() == []
|
||||
|
||||
def test_unsigned_accepted_when_no_key(self) -> None:
|
||||
"""With no key at all, an unsigned entry is tolerated."""
|
||||
_state.FOOD_LOG_FILE.write_text(
|
||||
json.dumps({_state._today(): [{"kcal": 5}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.object(_state, "compute_entry_hmac", return_value=None):
|
||||
assert len(today_entries()) == 1
|
||||
|
||||
def test_load_log_drops_emptied_days(self) -> None:
|
||||
"""A day whose every entry is invalid is omitted entirely."""
|
||||
_state.FOOD_LOG_FILE.write_text(
|
||||
json.dumps({_state._today(): [{"kcal": 1}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load_log() == {}
|
||||
|
||||
|
||||
class TestBudgetViews:
|
||||
"""Remaining budget and the qualitative band."""
|
||||
|
||||
def test_remaining_requires_budget(self) -> None:
|
||||
"""With no budget sealed, remaining_budget raises."""
|
||||
with pytest.raises(BudgetNotInitializedError):
|
||||
remaining_budget()
|
||||
|
||||
def test_remaining_value(self) -> None:
|
||||
"""Remaining is budget minus today's total."""
|
||||
seal_budget(2000)
|
||||
log_meal("lunch", _nut(500), slot=12)
|
||||
assert remaining_budget() == 1500.0
|
||||
|
||||
def test_band_on_track(self) -> None:
|
||||
"""Well under the warn fraction is 'on track'."""
|
||||
seal_budget(2000)
|
||||
log_meal("a", _nut(500), slot=8)
|
||||
assert consumption_band() == "on track"
|
||||
|
||||
def test_band_approaching(self) -> None:
|
||||
"""At or above the warn fraction but under budget is 'approaching limit'."""
|
||||
seal_budget(2000)
|
||||
log_meal("a", _nut(1700), slot=8)
|
||||
assert consumption_band() == "approaching limit"
|
||||
|
||||
def test_band_over(self) -> None:
|
||||
"""At or above budget is 'OVER BUDGET'."""
|
||||
seal_budget(2000)
|
||||
log_meal("a", _nut(2100), slot=8)
|
||||
assert consumption_band() == "OVER BUDGET"
|
||||
|
||||
|
||||
class TestUndo:
|
||||
"""Removing the most recent entry."""
|
||||
|
||||
def test_nothing_to_undo(self) -> None:
|
||||
"""An empty day undoes to None."""
|
||||
assert undo_last_today() is None
|
||||
|
||||
def test_undo_leaves_earlier_entries(self) -> None:
|
||||
"""Undo removes only the last entry when others remain."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
log_meal("b", _nut(200), slot=12)
|
||||
removed = undo_last_today()
|
||||
assert removed is not None
|
||||
assert removed["desc"] == "b"
|
||||
assert today_total_kcal() == 100.0
|
||||
|
||||
def test_undo_last_entry_clears_day(self) -> None:
|
||||
"""Undoing the only entry removes the day from the log."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert _state._read_raw_log() == {}
|
||||
Loading…
Reference in New Issue
Block a user