mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
Split _state.py and _tk_fakes.py; make the food-log redirect fail closed
Eighth batch of the 250-line sweep (over-cap count 28 -> 25). _state.py 366 -> 197 (+ _state_today.py, _state_sync.py) test_state.py 400 -> 221 (+ test_state_today.py, test_state_sync.py) _tk_fakes.py 368 -> 167 (+ _tk_fakes_containers.py, _tk_fakes_canvas.py) _state.py is the file conftest patches FOOD_LOG_FILE on, so the split is constrained rather than free: `FOOD_LOG_FILE` and the two functions that open it stay put, and the derived read model (_state_today) and the sync/undo helpers (_state_sync) reach the file only through _state's private _read_raw_log/_write_log. Callers were rewired to import from the new modules directly rather than via a circular re-export. That constraint is now enforced instead of merely documented. Moving a reader of FOOD_LOG_FILE into a sibling would leave conftest's patch pointing at a name nothing reads: check_patch_targets.py still exits 0 (the path resolves) and the runtime is unchanged (a file write, not a network call), so the suite would go green while writing to the real ~/.local/share/diet_guard and the next sync tick would publish it. test_state_redirect.py asserts both halves -- only _constants and _state may name the constant, and the redirect demonstrably moves a real write off the live path. Verified it fails closed by adding a FOOD_LOG_FILE reader to _state_today: it named that module exactly. _tk_fakes.py keeps _FAKE_TK/_FAKE_TTK and gains an explicit __all__, since the re-exports are load-bearing and ruff would otherwise strip them as unused. The identity canary from the previous commit covers the split: breaking one entry in the assembly (Canvas=object) fails 8 gate tests loudly rather than silently building real widgets. Verified: 706 Python tests / 100% branch coverage, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
parent
8f2d1011c7
commit
af461d3157
@ -28,7 +28,7 @@ repos:
|
||||
args: [--pytest-test-first]
|
||||
# Shared fakes and fixtures, not test modules. Split out of conftest.py
|
||||
# and test_sync.py only because those files passed the 250-line cap.
|
||||
exclude: ^diet_guard/tests/_(tk_fakes|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes)\.py$
|
||||
exclude: ^diet_guard/tests/_(tk_fakes(_containers|_canvas)?|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes)\.py$
|
||||
- id: check-ast
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.20+1
|
||||
version: 1.0.21+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -35,11 +35,15 @@ from diet_guard._gate import due_slots
|
||||
from diet_guard._slots import day_slots, slot_label
|
||||
from diet_guard._state import (
|
||||
entry_kcal,
|
||||
)
|
||||
from diet_guard._state_sync import (
|
||||
undo_last_today,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
logged_slots_today,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
undo_last_today,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@ -18,7 +18,12 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._slots import missing_slots, slot_label
|
||||
from diet_guard._state import logged_slots_today, now_local
|
||||
from diet_guard._state import (
|
||||
now_local,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
logged_slots_today,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
@ -15,6 +15,8 @@ from diet_guard._budget import BudgetError, daily_budget, protein_target_g
|
||||
from diet_guard._gatelock_nutrition import _GateNutrition
|
||||
from diet_guard._state import (
|
||||
entry_kcal,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
|
||||
@ -24,7 +24,9 @@ from diet_guard._gatelock_ui import (
|
||||
)
|
||||
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
||||
from diet_guard._resolve import suggest_foods
|
||||
from diet_guard._state import today_total_kcal
|
||||
from diet_guard._state_today import (
|
||||
today_total_kcal,
|
||||
)
|
||||
|
||||
|
||||
def _format_preview(nutrition: Nutrition) -> str:
|
||||
|
||||
@ -26,10 +26,12 @@ from diet_guard._gate import due_slots
|
||||
from diet_guard._mcp_server import READS_ONLY, mcp
|
||||
from diet_guard._slots import current_slot, day_slots, slot_label
|
||||
from diet_guard._state import (
|
||||
consumption_band,
|
||||
load_log,
|
||||
logged_slots_today,
|
||||
now_local,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
consumption_band,
|
||||
logged_slots_today,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
|
||||
@ -22,9 +22,8 @@ from gatelock.log_integrity import (
|
||||
verify_entry_hmac,
|
||||
)
|
||||
|
||||
from diet_guard._budget import daily_budget
|
||||
from diet_guard._coerce import as_float
|
||||
from diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
|
||||
from diet_guard._constants import FOOD_LOG_FILE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._estimator import Nutrition
|
||||
@ -195,172 +194,3 @@ def load_log() -> DayLog:
|
||||
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.
|
||||
|
||||
Nothing produces a slot-less entry any more: the phone's "Snack" chip was
|
||||
removed on 2026-08-14, and every remaining writer resolves a concrete hour
|
||||
through ``slot_for_log`` -- the CLI (:mod:`_cli`), the gate
|
||||
(:mod:`_gatelock_mealflow`) and the MCP tool (:mod:`_mcp`, which falls back
|
||||
to it whenever its ``slot`` argument is omitted or None). The check below
|
||||
is still load-bearing for entries already on disk and arriving over sync --
|
||||
removing it would make every historical snack retroactively satisfy a meal
|
||||
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 daily budget (may be negative).
|
||||
|
||||
Raises:
|
||||
BudgetError: If the budget is uninitialized or its file is corrupt;
|
||||
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 read_raw_log() -> DayLog:
|
||||
"""Return the log exactly as stored, including tombstoned/invalid entries.
|
||||
|
||||
Public counterpart of :func:`_read_raw_log`, for the sync orchestration
|
||||
(:mod:`diet_guard._sync`), which must see tombstones to merge them (the
|
||||
filtered :func:`load_log` drops them) and must not discard an entry that
|
||||
fails verification just because a phone-origin copy was never signed.
|
||||
"""
|
||||
return _read_raw_log()
|
||||
|
||||
|
||||
def write_raw_log(log: DayLog) -> None:
|
||||
"""Persist ``log`` verbatim, overwriting the file on disk.
|
||||
|
||||
Public counterpart of :func:`_write_log`, for :mod:`diet_guard._sync` to
|
||||
write back a merged log after re-signing it.
|
||||
"""
|
||||
_write_log(log)
|
||||
|
||||
|
||||
def resign_entry(entry: dict[str, object]) -> dict[str, object]:
|
||||
"""Return a copy of ``entry`` with a freshly computed ``hmac``.
|
||||
|
||||
Strips any existing signature first, mirroring :func:`undo_last_today`:
|
||||
a signature computed on another device (or none, if the phone -- which
|
||||
never holds the shared key -- produced this entry) cannot be trusted
|
||||
as-is, and recomputing is the only way :func:`_entry_is_valid` will
|
||||
accept it back on the next read. A no-op (signature-wise) when no HMAC
|
||||
key is available locally, matching :func:`log_meal`'s degrade-gracefully
|
||||
behavior.
|
||||
|
||||
Args:
|
||||
entry: A log entry, signed or not.
|
||||
|
||||
Returns:
|
||||
A new dict equal to ``entry`` except for its ``hmac`` field.
|
||||
"""
|
||||
resigned = dict(entry)
|
||||
resigned.pop("hmac", None)
|
||||
signature = compute_entry_hmac(resigned)
|
||||
if signature is not None:
|
||||
resigned["hmac"] = signature
|
||||
return resigned
|
||||
|
||||
|
||||
def undo_last_today() -> dict[str, object] | None:
|
||||
"""Tombstone today's most recently logged, not-yet-undone entry.
|
||||
|
||||
Marks the entry ``deleted`` in place and re-signs it, rather than
|
||||
physically removing it: a sync merge with another device only ever
|
||||
*adds* entries it hasn't seen before, so a physical delete here would be
|
||||
silently resurrected the next time that device's stale copy is pulled
|
||||
back in. The tombstone travels with the entry instead, and every reader
|
||||
(:func:`load_log`, the food-bank rebuild) already skips it.
|
||||
|
||||
Operates on the raw log so a mistaken entry can always be undone, even
|
||||
one that would not pass verification.
|
||||
|
||||
Returns:
|
||||
The tombstoned entry, or None if nothing undoable was logged today.
|
||||
"""
|
||||
log = _read_raw_log()
|
||||
today = _today()
|
||||
entries = log.get(today)
|
||||
if not entries:
|
||||
return None
|
||||
for entry in reversed(entries):
|
||||
if entry.get("deleted"):
|
||||
continue
|
||||
entry["deleted"] = True
|
||||
entry.pop("hmac", None)
|
||||
signature = compute_entry_hmac(entry)
|
||||
if signature is not None:
|
||||
entry["hmac"] = signature
|
||||
_write_log(log)
|
||||
return entry
|
||||
return None
|
||||
|
||||
111
diet_guard/_state_sync.py
Normal file
111
diet_guard/_state_sync.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Raw-log access, re-signing, and undo for the signed food log.
|
||||
|
||||
Split out of :mod:`._state` to keep both files under the repo's 250-line
|
||||
limit. These are the *write*-side and sync-side helpers: unverified reads
|
||||
(:func:`read_raw_log`), the merge write-back (:func:`write_raw_log`), the
|
||||
per-entry re-sign the sync tick applies to every persisted entry
|
||||
(:func:`resign_entry`), and the tombstoning undo.
|
||||
|
||||
They reach the file only through :mod:`._state`'s private ``_read_raw_log`` /
|
||||
``_write_log``, which is where ``FOOD_LOG_FILE`` stays -- ``tests/conftest.py``
|
||||
redirects the log by patching ``diet_guard._state.FOOD_LOG_FILE``, so a copy
|
||||
of that constant here would be a patch the tests no longer reach. The names
|
||||
are re-exported from :mod:`._state` for the existing call sites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from diet_guard._state import _read_raw_log, _today, _write_log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._state import DayLog
|
||||
|
||||
__all__ = [
|
||||
"read_raw_log",
|
||||
"resign_entry",
|
||||
"undo_last_today",
|
||||
"write_raw_log",
|
||||
]
|
||||
|
||||
|
||||
def read_raw_log() -> DayLog:
|
||||
"""Return the log exactly as stored, including tombstoned/invalid entries.
|
||||
|
||||
Public counterpart of :func:`_read_raw_log`, for the sync orchestration
|
||||
(:mod:`diet_guard._sync`), which must see tombstones to merge them (the
|
||||
filtered :func:`load_log` drops them) and must not discard an entry that
|
||||
fails verification just because a phone-origin copy was never signed.
|
||||
"""
|
||||
return _read_raw_log()
|
||||
|
||||
|
||||
def write_raw_log(log: DayLog) -> None:
|
||||
"""Persist ``log`` verbatim, overwriting the file on disk.
|
||||
|
||||
Public counterpart of :func:`_write_log`, for :mod:`diet_guard._sync` to
|
||||
write back a merged log after re-signing it.
|
||||
"""
|
||||
_write_log(log)
|
||||
|
||||
|
||||
def resign_entry(entry: dict[str, object]) -> dict[str, object]:
|
||||
"""Return a copy of ``entry`` with a freshly computed ``hmac``.
|
||||
|
||||
Strips any existing signature first, mirroring :func:`undo_last_today`:
|
||||
a signature computed on another device (or none, if the phone -- which
|
||||
never holds the shared key -- produced this entry) cannot be trusted
|
||||
as-is, and recomputing is the only way :func:`_entry_is_valid` will
|
||||
accept it back on the next read. A no-op (signature-wise) when no HMAC
|
||||
key is available locally, matching :func:`log_meal`'s degrade-gracefully
|
||||
behavior.
|
||||
|
||||
Args:
|
||||
entry: A log entry, signed or not.
|
||||
|
||||
Returns:
|
||||
A new dict equal to ``entry`` except for its ``hmac`` field.
|
||||
"""
|
||||
resigned = dict(entry)
|
||||
resigned.pop("hmac", None)
|
||||
signature = compute_entry_hmac(resigned)
|
||||
if signature is not None:
|
||||
resigned["hmac"] = signature
|
||||
return resigned
|
||||
|
||||
|
||||
def undo_last_today() -> dict[str, object] | None:
|
||||
"""Tombstone today's most recently logged, not-yet-undone entry.
|
||||
|
||||
Marks the entry ``deleted`` in place and re-signs it, rather than
|
||||
physically removing it: a sync merge with another device only ever
|
||||
*adds* entries it hasn't seen before, so a physical delete here would be
|
||||
silently resurrected the next time that device's stale copy is pulled
|
||||
back in. The tombstone travels with the entry instead, and every reader
|
||||
(:func:`load_log`, the food-bank rebuild) already skips it.
|
||||
|
||||
Operates on the raw log so a mistaken entry can always be undone, even
|
||||
one that would not pass verification.
|
||||
|
||||
Returns:
|
||||
The tombstoned entry, or None if nothing undoable was logged today.
|
||||
"""
|
||||
log = _read_raw_log()
|
||||
today = _today()
|
||||
entries = log.get(today)
|
||||
if not entries:
|
||||
return None
|
||||
for entry in reversed(entries):
|
||||
if entry.get("deleted"):
|
||||
continue
|
||||
entry["deleted"] = True
|
||||
entry.pop("hmac", None)
|
||||
signature = compute_entry_hmac(entry)
|
||||
if signature is not None:
|
||||
entry["hmac"] = signature
|
||||
_write_log(log)
|
||||
return entry
|
||||
return None
|
||||
127
diet_guard/_state_today.py
Normal file
127
diet_guard/_state_today.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Today's read model over the signed food log.
|
||||
|
||||
Split out of :mod:`._state` to keep both files under the repo's 250-line
|
||||
limit. Everything here is *derived*: it reads through
|
||||
:func:`diet_guard._state.load_log` and never touches the file itself.
|
||||
|
||||
That division is deliberate rather than cosmetic. ``FOOD_LOG_FILE`` and the
|
||||
two functions that open it stay in :mod:`._state`, because
|
||||
``tests/conftest.py`` redirects the log by patching
|
||||
``diet_guard._state.FOOD_LOG_FILE``. Moving a reader of that constant into
|
||||
this module would leave the patch pointing at a name nothing reads any more --
|
||||
it would still resolve, ``check_patch_targets.py`` would still exit 0, and the
|
||||
suite would quietly write to the real ``~/.local/share/diet_guard``.
|
||||
|
||||
The names are re-exported from :mod:`._state`, which is where a dozen modules
|
||||
already import them from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from diet_guard._budget import daily_budget
|
||||
from diet_guard._constants import BUDGET_WARN_FRACTION
|
||||
from diet_guard._state import (
|
||||
_entry_float,
|
||||
_today,
|
||||
entry_kcal,
|
||||
load_log,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"consumption_band",
|
||||
"logged_slots_today",
|
||||
"remaining_budget",
|
||||
"today_entries",
|
||||
"today_total_kcal",
|
||||
"today_total_macros",
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Nothing produces a slot-less entry any more: the phone's "Snack" chip was
|
||||
removed on 2026-08-14, and every remaining writer resolves a concrete hour
|
||||
through ``slot_for_log`` -- the CLI (:mod:`_cli`), the gate
|
||||
(:mod:`_gatelock_mealflow`) and the MCP tool (:mod:`_mcp`, which falls back
|
||||
to it whenever its ``slot`` argument is omitted or None). The check below
|
||||
is still load-bearing for entries already on disk and arriving over sync --
|
||||
removing it would make every historical snack retroactively satisfy a meal
|
||||
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 daily budget (may be negative).
|
||||
|
||||
Raises:
|
||||
BudgetError: If the budget is uninitialized or its file is corrupt;
|
||||
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"
|
||||
@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import (
|
||||
DeviceIdentity,
|
||||
@ -36,7 +37,11 @@ from diet_guard._constants import (
|
||||
)
|
||||
from diet_guard._device import device_identity
|
||||
from diet_guard._foodbank import rebuild_food_bank
|
||||
from diet_guard._state import DayLog, read_raw_log, resign_entry, write_raw_log
|
||||
from diet_guard._state_sync import (
|
||||
read_raw_log,
|
||||
resign_entry,
|
||||
write_raw_log,
|
||||
)
|
||||
from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank
|
||||
from diet_guard._sync_client import _client_for_run
|
||||
from diet_guard._sync_errors import SyncError
|
||||
@ -47,6 +52,11 @@ from diet_guard.sync_merge import (
|
||||
parse_remote_log,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._state import (
|
||||
DayLog,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# One small text node per device, each written only by its owner. Deliberately
|
||||
|
||||
@ -22,6 +22,35 @@ from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar
|
||||
from diet_guard.tests._tk_fakes_containers import (
|
||||
FakeNotebook,
|
||||
FakeRadiobutton,
|
||||
FakeStyle,
|
||||
FakeWidget,
|
||||
)
|
||||
|
||||
# Imported by name so `from _tk_fakes import X` keeps working for every fake,
|
||||
# and so `_gate_fixtures`'s `_FAKE_TK`/`_FAKE_TTK` import has one home. Listed
|
||||
# explicitly (rather than left as incidental imports) because ruff would
|
||||
# otherwise strip the re-exports as unused -- and the identity assertion in
|
||||
# `test_gate_tk_modules.py` is what catches it if one ever goes missing.
|
||||
__all__ = [
|
||||
"_FAKE_TK",
|
||||
"_FAKE_TTK",
|
||||
"FakeCanvas",
|
||||
"FakeEntry",
|
||||
"FakeListbox",
|
||||
"FakeNotebook",
|
||||
"FakeRadiobutton",
|
||||
"FakeScrollbar",
|
||||
"FakeStyle",
|
||||
"FakeText",
|
||||
"FakeVar",
|
||||
"FakeWidget",
|
||||
"_FakeTclError",
|
||||
]
|
||||
|
||||
|
||||
class _FakeTclError(Exception):
|
||||
"""Stand-in for ``tkinter.TclError`` (a real, catchable exception)."""
|
||||
@ -115,234 +144,6 @@ class FakeListbox:
|
||||
pass
|
||||
|
||||
|
||||
class FakeWidget:
|
||||
"""A generic no-op widget for Frame/Label/Button/OptionMenu.
|
||||
|
||||
``configure``/``config`` record their kwargs into ``configured`` (rather
|
||||
than discarding them) so a test can assert on a widget's last-set color
|
||||
or text, e.g. the calendar's per-cell status coloring.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.configured: dict[str, object] = dict(kwargs)
|
||||
self.packed = False
|
||||
|
||||
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
self.packed = True
|
||||
return self
|
||||
|
||||
def pack_forget(self, *args: object, **kwargs: object) -> None:
|
||||
"""Un-pack. The viewport hides its scrollbar whenever content fits."""
|
||||
self.packed = False
|
||||
|
||||
def place(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def grid(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
self.configured.update(kwargs)
|
||||
return self
|
||||
|
||||
config = configure
|
||||
|
||||
def bind(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def register(self, func: object) -> object:
|
||||
"""Stand in for Tk's Tcl-command registration: return the callable as-is."""
|
||||
return func
|
||||
|
||||
def winfo_children(self) -> list[object]:
|
||||
"""No children: the fakes are not a real widget tree."""
|
||||
return []
|
||||
|
||||
def winfo_toplevel(self) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def update_idletasks(self) -> None:
|
||||
pass
|
||||
|
||||
def cget(self, key: str) -> object:
|
||||
return self.configured.get(key, "")
|
||||
|
||||
def focus_get(self) -> None:
|
||||
"""Nothing holds focus in a fake tree."""
|
||||
|
||||
# Geometry queries. The scroll viewport asks for these to size itself and
|
||||
# to decide whether content still fits; zero means "nothing measured yet",
|
||||
# which is the honest answer for a fake with no layout.
|
||||
def winfo_reqwidth(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_reqheight(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_width(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_height(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_rooty(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_screenwidth(self) -> int:
|
||||
return 1366
|
||||
|
||||
def winfo_screenheight(self) -> int:
|
||||
return 768
|
||||
|
||||
|
||||
class FakeNotebook(FakeWidget):
|
||||
"""A functional ``ttk.Notebook``: tracks added tabs and the selection."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tabs: list[tuple[object, str]] = []
|
||||
self._selected = 0
|
||||
self.traversal_enabled = False
|
||||
|
||||
def add(self, child: object, *, text: str = "") -> None:
|
||||
self.tabs.append((child, text))
|
||||
|
||||
def enable_traversal(self) -> None:
|
||||
"""Record that Ctrl+Tab / Ctrl+PageDown traversal was requested.
|
||||
|
||||
Tracked rather than ignored so a test can assert it happened: ttk only
|
||||
installs those toplevel bindings on request, and forgetting the call
|
||||
leaves the tab-switching keys silently dead.
|
||||
"""
|
||||
self.traversal_enabled = True
|
||||
|
||||
def select(self, tab_id: object = None) -> int | None:
|
||||
if tab_id is None:
|
||||
return self._selected
|
||||
if isinstance(tab_id, int):
|
||||
self._selected = tab_id
|
||||
else:
|
||||
for index, (child, _label) in enumerate(self.tabs):
|
||||
if child is tab_id:
|
||||
self._selected = index
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
class FakeRadiobutton(FakeWidget):
|
||||
"""A functional ``tk.Radiobutton``: selecting sets the var and fires.
|
||||
|
||||
Functional rather than a no-op because this is the gate's grams/items unit
|
||||
selector, which used to be a keyboard-unreachable ``OptionMenu``. A test
|
||||
needs to be able to drive it the way a keyboard user now can.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.value = kwargs.get("value")
|
||||
self._variable = kwargs.get("variable")
|
||||
self._command = kwargs.get("command")
|
||||
|
||||
def invoke(self) -> None:
|
||||
"""Select this option, as Space/Return does on a real Radiobutton."""
|
||||
if self._variable is not None and hasattr(self._variable, "set"):
|
||||
self._variable.set(self.value)
|
||||
if callable(self._command):
|
||||
self._command()
|
||||
|
||||
select = invoke
|
||||
|
||||
|
||||
class FakeScrollbar(FakeWidget):
|
||||
"""A ``tk.Scrollbar`` stand-in that records the thumb position."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fractions: tuple[str, ...] = ()
|
||||
|
||||
def set(self, *fractions: str) -> None:
|
||||
self.fractions = fractions
|
||||
|
||||
|
||||
class FakeCanvas(FakeWidget):
|
||||
"""A functional ``tk.Canvas`` stand-in for ``gatelock.ScrollableSurface``.
|
||||
|
||||
Tracks the scroll offset so a test can assert the viewport actually moved,
|
||||
which is the whole point of the widget being there: the gate's content can
|
||||
exceed a 768px screen, and before the viewport existed the overflow was
|
||||
silently clipped off both edges at once.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.items: list[object] = []
|
||||
self.offset = 0.0
|
||||
self._children: list[object] = []
|
||||
|
||||
def create_window(self, *args: object, **kwargs: object) -> int:
|
||||
window = kwargs.get("window")
|
||||
if window is not None:
|
||||
self.items.append(window)
|
||||
self._children.append(window)
|
||||
return len(self.items)
|
||||
|
||||
def itemconfigure(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def coords(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def bbox(self, *_args: object) -> tuple[int, int, int, int]:
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
def yview(self) -> tuple[float, float]:
|
||||
return (self.offset, 1.0)
|
||||
|
||||
def yview_scroll(self, number: int, _what: str) -> None:
|
||||
self.offset = max(0.0, self.offset + number)
|
||||
|
||||
def yview_moveto(self, fraction: float) -> None:
|
||||
self.offset = fraction
|
||||
|
||||
def canvasy(self, screen_y: float) -> float:
|
||||
return screen_y
|
||||
|
||||
def winfo_children(self) -> list[object]:
|
||||
return list(self._children)
|
||||
|
||||
def winfo_height(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_toplevel(self) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def update_idletasks(self) -> None:
|
||||
pass
|
||||
|
||||
def cget(self, key: str) -> object:
|
||||
return self.configured.get(key, "")
|
||||
|
||||
def focus_get(self) -> None:
|
||||
"""Nothing holds focus in a fake tree."""
|
||||
|
||||
|
||||
class FakeStyle:
|
||||
"""A no-op stand-in for ``ttk.Style`` -- records calls, applies nothing."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.theme: str | None = None
|
||||
self.configured: dict[str, dict[str, object]] = {}
|
||||
|
||||
def theme_use(self, theme_name: str) -> None:
|
||||
self.theme = theme_name
|
||||
|
||||
def configure(self, style_name: str, **kwargs: object) -> None:
|
||||
self.configured.setdefault(style_name, {}).update(kwargs)
|
||||
|
||||
def map(self, style_name: str, **kwargs: object) -> None:
|
||||
self.configured.setdefault(style_name, {}).update(kwargs)
|
||||
|
||||
|
||||
_FAKE_TK = SimpleNamespace(
|
||||
END="end",
|
||||
TclError=_FakeTclError,
|
||||
|
||||
98
diet_guard/tests/_tk_fakes_canvas.py
Normal file
98
diet_guard/tests/_tk_fakes_canvas.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Canvas and scrollbar fakes for the gate's scroll-viewport tests.
|
||||
|
||||
Extracted from ``conftest.py`` purely for size: the widget tree the gate builds
|
||||
now spans several modules plus ``gatelock``, and the fakes grew past this repo's
|
||||
500-line file cap when the scroll viewport and the radio-button unit selector
|
||||
were added.
|
||||
|
||||
Stays under ``tests/`` deliberately. It was briefly moved into the package to
|
||||
satisfy the ``test_*.py`` naming hook, but that lost the ruff ``ARG`` exemption
|
||||
that ``**/tests/**`` already grants -- and a fake widget's whole job is to
|
||||
accept arguments it ignores. Weakening ruff for package code to accommodate
|
||||
test scaffolding is the wrong trade; excluding one clearly-named support file
|
||||
from a naming convention is the cheaper one.
|
||||
|
||||
The fakes are *functional* where behaviour matters -- selecting a radio button
|
||||
really sets its variable and fires its command, and the canvas really tracks a
|
||||
scroll offset -- so a test can drive the gate the way a keyboard user now can,
|
||||
rather than merely asserting that a widget was constructed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from diet_guard.tests._tk_fakes_containers import FakeWidget
|
||||
|
||||
__all__ = ["FakeCanvas", "FakeScrollbar"]
|
||||
|
||||
|
||||
class FakeCanvas(FakeWidget):
|
||||
"""A functional ``tk.Canvas`` stand-in for ``gatelock.ScrollableSurface``.
|
||||
|
||||
Tracks the scroll offset so a test can assert the viewport actually moved,
|
||||
which is the whole point of the widget being there: the gate's content can
|
||||
exceed a 768px screen, and before the viewport existed the overflow was
|
||||
silently clipped off both edges at once.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.items: list[object] = []
|
||||
self.offset = 0.0
|
||||
self._children: list[object] = []
|
||||
|
||||
def create_window(self, *args: object, **kwargs: object) -> int:
|
||||
window = kwargs.get("window")
|
||||
if window is not None:
|
||||
self.items.append(window)
|
||||
self._children.append(window)
|
||||
return len(self.items)
|
||||
|
||||
def itemconfigure(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def coords(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def bbox(self, *_args: object) -> tuple[int, int, int, int]:
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
def yview(self) -> tuple[float, float]:
|
||||
return (self.offset, 1.0)
|
||||
|
||||
def yview_scroll(self, number: int, _what: str) -> None:
|
||||
self.offset = max(0.0, self.offset + number)
|
||||
|
||||
def yview_moveto(self, fraction: float) -> None:
|
||||
self.offset = fraction
|
||||
|
||||
def canvasy(self, screen_y: float) -> float:
|
||||
return screen_y
|
||||
|
||||
def winfo_children(self) -> list[object]:
|
||||
return list(self._children)
|
||||
|
||||
def winfo_height(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_toplevel(self) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def update_idletasks(self) -> None:
|
||||
pass
|
||||
|
||||
def cget(self, key: str) -> object:
|
||||
return self.configured.get(key, "")
|
||||
|
||||
def focus_get(self) -> None:
|
||||
"""Nothing holds focus in a fake tree."""
|
||||
|
||||
|
||||
class FakeScrollbar(FakeWidget):
|
||||
"""A ``tk.Scrollbar`` stand-in that records the thumb position."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fractions: tuple[str, ...] = ()
|
||||
|
||||
def set(self, *fractions: str) -> None:
|
||||
self.fractions = fractions
|
||||
183
diet_guard/tests/_tk_fakes_containers.py
Normal file
183
diet_guard/tests/_tk_fakes_containers.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""Container and notebook fakes for the gate's display-free tests.
|
||||
|
||||
Extracted from ``conftest.py`` purely for size: the widget tree the gate builds
|
||||
now spans several modules plus ``gatelock``, and the fakes grew past this repo's
|
||||
500-line file cap when the scroll viewport and the radio-button unit selector
|
||||
were added.
|
||||
|
||||
Stays under ``tests/`` deliberately. It was briefly moved into the package to
|
||||
satisfy the ``test_*.py`` naming hook, but that lost the ruff ``ARG`` exemption
|
||||
that ``**/tests/**`` already grants -- and a fake widget's whole job is to
|
||||
accept arguments it ignores. Weakening ruff for package code to accommodate
|
||||
test scaffolding is the wrong trade; excluding one clearly-named support file
|
||||
from a naming convention is the cheaper one.
|
||||
|
||||
The fakes are *functional* where behaviour matters -- selecting a radio button
|
||||
really sets its variable and fires its command, and the canvas really tracks a
|
||||
scroll offset -- so a test can drive the gate the way a keyboard user now can,
|
||||
rather than merely asserting that a widget was constructed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"FakeNotebook",
|
||||
"FakeRadiobutton",
|
||||
"FakeStyle",
|
||||
"FakeWidget",
|
||||
]
|
||||
|
||||
|
||||
class FakeWidget:
|
||||
"""A generic no-op widget for Frame/Label/Button/OptionMenu.
|
||||
|
||||
``configure``/``config`` record their kwargs into ``configured`` (rather
|
||||
than discarding them) so a test can assert on a widget's last-set color
|
||||
or text, e.g. the calendar's per-cell status coloring.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.configured: dict[str, object] = dict(kwargs)
|
||||
self.packed = False
|
||||
|
||||
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
self.packed = True
|
||||
return self
|
||||
|
||||
def pack_forget(self, *args: object, **kwargs: object) -> None:
|
||||
"""Un-pack. The viewport hides its scrollbar whenever content fits."""
|
||||
self.packed = False
|
||||
|
||||
def place(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def grid(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
self.configured.update(kwargs)
|
||||
return self
|
||||
|
||||
config = configure
|
||||
|
||||
def bind(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def register(self, func: object) -> object:
|
||||
"""Stand in for Tk's Tcl-command registration: return the callable as-is."""
|
||||
return func
|
||||
|
||||
def winfo_children(self) -> list[object]:
|
||||
"""No children: the fakes are not a real widget tree."""
|
||||
return []
|
||||
|
||||
def winfo_toplevel(self) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def update_idletasks(self) -> None:
|
||||
pass
|
||||
|
||||
def cget(self, key: str) -> object:
|
||||
return self.configured.get(key, "")
|
||||
|
||||
def focus_get(self) -> None:
|
||||
"""Nothing holds focus in a fake tree."""
|
||||
|
||||
# Geometry queries. The scroll viewport asks for these to size itself and
|
||||
# to decide whether content still fits; zero means "nothing measured yet",
|
||||
# which is the honest answer for a fake with no layout.
|
||||
def winfo_reqwidth(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_reqheight(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_width(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_height(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_rooty(self) -> int:
|
||||
return 0
|
||||
|
||||
def winfo_screenwidth(self) -> int:
|
||||
return 1366
|
||||
|
||||
def winfo_screenheight(self) -> int:
|
||||
return 768
|
||||
|
||||
|
||||
class FakeNotebook(FakeWidget):
|
||||
"""A functional ``ttk.Notebook``: tracks added tabs and the selection."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tabs: list[tuple[object, str]] = []
|
||||
self._selected = 0
|
||||
self.traversal_enabled = False
|
||||
|
||||
def add(self, child: object, *, text: str = "") -> None:
|
||||
self.tabs.append((child, text))
|
||||
|
||||
def enable_traversal(self) -> None:
|
||||
"""Record that Ctrl+Tab / Ctrl+PageDown traversal was requested.
|
||||
|
||||
Tracked rather than ignored so a test can assert it happened: ttk only
|
||||
installs those toplevel bindings on request, and forgetting the call
|
||||
leaves the tab-switching keys silently dead.
|
||||
"""
|
||||
self.traversal_enabled = True
|
||||
|
||||
def select(self, tab_id: object = None) -> int | None:
|
||||
if tab_id is None:
|
||||
return self._selected
|
||||
if isinstance(tab_id, int):
|
||||
self._selected = tab_id
|
||||
else:
|
||||
for index, (child, _label) in enumerate(self.tabs):
|
||||
if child is tab_id:
|
||||
self._selected = index
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
class FakeRadiobutton(FakeWidget):
|
||||
"""A functional ``tk.Radiobutton``: selecting sets the var and fires.
|
||||
|
||||
Functional rather than a no-op because this is the gate's grams/items unit
|
||||
selector, which used to be a keyboard-unreachable ``OptionMenu``. A test
|
||||
needs to be able to drive it the way a keyboard user now can.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.value = kwargs.get("value")
|
||||
self._variable = kwargs.get("variable")
|
||||
self._command = kwargs.get("command")
|
||||
|
||||
def invoke(self) -> None:
|
||||
"""Select this option, as Space/Return does on a real Radiobutton."""
|
||||
if self._variable is not None and hasattr(self._variable, "set"):
|
||||
self._variable.set(self.value)
|
||||
if callable(self._command):
|
||||
self._command()
|
||||
|
||||
select = invoke
|
||||
|
||||
|
||||
class FakeStyle:
|
||||
"""A no-op stand-in for ``ttk.Style`` -- records calls, applies nothing."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.theme: str | None = None
|
||||
self.configured: dict[str, dict[str, object]] = {}
|
||||
|
||||
def theme_use(self, theme_name: str) -> None:
|
||||
self.theme = theme_name
|
||||
|
||||
def configure(self, style_name: str, **kwargs: object) -> None:
|
||||
self.configured.setdefault(style_name, {}).update(kwargs)
|
||||
|
||||
def map(self, style_name: str, **kwargs: object) -> None:
|
||||
self.configured.setdefault(style_name, {}).update(kwargs)
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from diet_guard import _daystatus, _state
|
||||
from diet_guard import _daystatus, _state, _state_sync
|
||||
from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule
|
||||
from diet_guard._daystatus import DayStatus
|
||||
from diet_guard._estimator import Nutrition
|
||||
@ -65,7 +65,7 @@ class TestDayStatus:
|
||||
# day is entirely absent from the filtered log rather than present
|
||||
# with an empty list -- confirm that reads as NOT_LOGGED, not GREEN.
|
||||
_state.log_meal("snack", Nutrition(300, 5, 40, 10, 100, "manual"))
|
||||
_state.undo_last_today()
|
||||
_state_sync.undo_last_today()
|
||||
today = _state._today()
|
||||
assert today not in _state.load_log()
|
||||
assert (
|
||||
|
||||
@ -10,29 +10,29 @@ from __future__ import annotations
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard import _state
|
||||
from diet_guard._budget import BudgetNotInitializedError, write_budget
|
||||
from diet_guard._estimator import Nutrition
|
||||
from diet_guard._state import (
|
||||
consumption_band,
|
||||
entry_kcal,
|
||||
load_log,
|
||||
log_meal,
|
||||
logged_slots_today,
|
||||
now_local,
|
||||
read_raw_log,
|
||||
remaining_budget,
|
||||
resign_entry,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
logged_slots_today,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
undo_last_today,
|
||||
write_raw_log,
|
||||
)
|
||||
|
||||
|
||||
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 _nut(
|
||||
kcal: float, *, protein: float = 0, carbs: float = 0, fat: float = 0
|
||||
) -> Nutrition:
|
||||
@ -106,25 +106,6 @@ class TestLogAndTotals:
|
||||
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."""
|
||||
|
||||
@ -195,39 +176,6 @@ class TestVerification:
|
||||
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."""
|
||||
write_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'."""
|
||||
write_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'."""
|
||||
write_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'."""
|
||||
write_budget(2000)
|
||||
log_meal("a", _nut(2100), slot=8)
|
||||
assert consumption_band() == "OVER BUDGET"
|
||||
|
||||
|
||||
class TestIdAndComponents:
|
||||
"""New per-entry fields the companion phone app's sync relies on."""
|
||||
|
||||
@ -262,139 +210,3 @@ class TestIdAndComponents:
|
||||
]
|
||||
entry = log_meal("dinner", _nut(165), slot=20, components=parts)
|
||||
assert entry["components"] == parts
|
||||
|
||||
|
||||
class TestUndo:
|
||||
"""Tombstoning 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 tombstones 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_tombstones_in_place(self) -> None:
|
||||
"""Undoing the only entry keeps it on disk, marked deleted."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
assert len(raw[day]) == 1
|
||||
assert raw[day][0]["deleted"] is True
|
||||
|
||||
def test_undo_tombstone_excluded_from_reads(self) -> None:
|
||||
"""A tombstoned entry no longer counts toward totals or slots."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert today_total_kcal() == 0.0
|
||||
assert today_entries() == []
|
||||
assert logged_slots_today() == set()
|
||||
|
||||
def test_undo_re_signs_the_tombstone(self) -> None:
|
||||
"""The mutated (tombstoned) entry still carries a valid signature."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
assert "hmac" in raw[day][0]
|
||||
|
||||
def test_undo_unsigned_when_no_key(self) -> None:
|
||||
"""Re-signing a tombstone with no key available leaves it unsigned."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
with patch.object(_state, "compute_entry_hmac", return_value=None):
|
||||
undo_last_today()
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
assert "hmac" not in raw[day][0]
|
||||
|
||||
def test_undo_skips_already_tombstoned(self) -> None:
|
||||
"""Undoing twice tombstones the prior entry, not the same one again."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
log_meal("b", _nut(200), slot=12)
|
||||
undo_last_today()
|
||||
second = undo_last_today()
|
||||
assert second is not None
|
||||
assert second["desc"] == "a"
|
||||
|
||||
def test_undo_nothing_left_once_all_tombstoned(self) -> None:
|
||||
"""Once every entry today is tombstoned, undo returns None."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert undo_last_today() is None
|
||||
|
||||
|
||||
class TestLoadLogSkipsTombstones:
|
||||
"""``load_log`` filters out deleted entries the same way as invalid ones."""
|
||||
|
||||
def test_day_with_only_a_tombstone_is_omitted(self) -> None:
|
||||
"""A day whose sole entry is tombstoned is dropped entirely."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert load_log() == {}
|
||||
|
||||
|
||||
class TestRawLogAccess:
|
||||
"""Public raw read/write, used by the sync orchestration."""
|
||||
|
||||
def test_read_raw_log_includes_tombstones(self) -> None:
|
||||
"""Unlike load_log, read_raw_log keeps a tombstoned entry."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
raw = read_raw_log()
|
||||
day = next(iter(raw))
|
||||
assert raw[day][0]["deleted"] is True
|
||||
|
||||
def test_write_raw_log_roundtrips(self) -> None:
|
||||
"""write_raw_log persists exactly what read_raw_log later returns."""
|
||||
log = {"2026-06-22": [{"id": "x", "time": "2026-06-22T08:00:00+02:00"}]}
|
||||
write_raw_log(log)
|
||||
assert read_raw_log() == log
|
||||
|
||||
def test_write_leaves_no_temp_file(self) -> None:
|
||||
"""A successful atomic write cleans up its temp file."""
|
||||
write_raw_log({"2026-06-22": [{"id": "x"}]})
|
||||
assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == []
|
||||
|
||||
def test_write_failure_preserves_prior_log(self) -> None:
|
||||
"""A failed replace leaves the old log intact and no temp behind.
|
||||
|
||||
This is the point of the atomic write: a concurrent reader (the gate
|
||||
now syncs while the timer may also write) never sees a torn or empty
|
||||
log just because a write was interrupted.
|
||||
"""
|
||||
write_raw_log({"2026-06-22": [{"id": "original"}]})
|
||||
with (
|
||||
patch("pathlib.Path.replace", side_effect=OSError("no space")),
|
||||
pytest.raises(OSError, match="no space"),
|
||||
):
|
||||
write_raw_log({"2026-06-22": [{"id": "clobbered"}]})
|
||||
assert read_raw_log() == {"2026-06-22": [{"id": "original"}]}
|
||||
assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == []
|
||||
|
||||
|
||||
class TestResignEntry:
|
||||
"""resign_entry recomputes the hmac so a merged entry validates again."""
|
||||
|
||||
def test_strips_and_recomputes_signature(self) -> None:
|
||||
"""A re-signed entry's hmac changes but verifies against the key."""
|
||||
entry = log_meal("a", _nut(100), slot=8)
|
||||
tampered = dict(entry, kcal=999.0)
|
||||
resigned = resign_entry(tampered)
|
||||
assert resigned["hmac"] != entry["hmac"]
|
||||
write_raw_log({"2026-06-22": [resigned]})
|
||||
with patch.object(_state, "_today", return_value="2026-06-22"):
|
||||
assert today_entries() == [resigned]
|
||||
|
||||
def test_no_op_signature_wise_when_no_key_available(self) -> None:
|
||||
"""Without an HMAC key, resign_entry produces no hmac field."""
|
||||
entry = log_meal("a", _nut(100), slot=8)
|
||||
with patch.object(_state, "compute_entry_hmac", return_value=None):
|
||||
resigned = resign_entry(entry)
|
||||
assert "hmac" not in resigned
|
||||
|
||||
72
diet_guard/tests/test_state_redirect.py
Normal file
72
diet_guard/tests/test_state_redirect.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Guard: the food log's test redirect must actually reach every reader.
|
||||
|
||||
``conftest._isolate_state`` redirects the log by patching
|
||||
``diet_guard._state.FOOD_LOG_FILE``. That patch reaches only code which
|
||||
resolves the constant *through that module's globals*. Split a function that
|
||||
opens the log into a sibling -- as the 250-line sweep did to ``_state.py`` --
|
||||
and the sibling reads its own copy of the name, the patch redirects nothing,
|
||||
and the suite writes to the real ``~/.local/share/diet_guard``, which the next
|
||||
sync tick publishes to every other device.
|
||||
|
||||
That failure is silent and green: ``check_patch_targets.py`` still exits 0
|
||||
(the dotted path resolves), and the ~5s runtime is unchanged (this is a file
|
||||
write, not a network call). Hence a direct assertion, in two parts: only
|
||||
``_state`` may name the constant, and the redirect demonstrably moves a real
|
||||
write off the live path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
from diet_guard import _state
|
||||
from diet_guard._constants import FOOD_LOG_FILE as REAL_FOOD_LOG_FILE
|
||||
|
||||
_PACKAGE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The one module allowed to open the food log. Everything else must go
|
||||
#: through its ``_read_raw_log`` / ``_write_log``.
|
||||
_LOG_FILE_OWNER = "_state"
|
||||
|
||||
|
||||
def _names_food_log_file(path: pathlib.Path) -> bool:
|
||||
"""Whether ``path`` mentions ``FOOD_LOG_FILE`` at all."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
|
||||
return any(
|
||||
(isinstance(node, ast.Name) and node.id == "FOOD_LOG_FILE")
|
||||
or (isinstance(node, ast.Attribute) and node.attr == "FOOD_LOG_FILE")
|
||||
for node in ast.walk(tree)
|
||||
)
|
||||
|
||||
|
||||
def test_only_state_names_the_log_file() -> None:
|
||||
"""``FOOD_LOG_FILE`` is referenced from exactly one package module.
|
||||
|
||||
Fails closed: a split that moves a log reader into a sibling shows up here
|
||||
as a named module, instead of as a test run quietly writing to the real
|
||||
food log.
|
||||
"""
|
||||
namers = sorted(
|
||||
path.stem for path in _PACKAGE_DIR.glob("*.py") if _names_food_log_file(path)
|
||||
)
|
||||
assert set(namers) <= {_LOG_FILE_OWNER, "_constants"}, (
|
||||
"FOOD_LOG_FILE must be named only by _constants (its definition) and "
|
||||
f"_state (the sole reader conftest patches); found: {namers}"
|
||||
)
|
||||
|
||||
|
||||
def test_redirect_moves_writes_off_the_live_log() -> None:
|
||||
"""Under the conftest redirect, a real write misses the live path.
|
||||
|
||||
The complement of the static check: proves the patched constant is the one
|
||||
the write path actually resolves, not merely that the name is in the right
|
||||
file.
|
||||
"""
|
||||
assert _state.FOOD_LOG_FILE != REAL_FOOD_LOG_FILE, (
|
||||
"conftest._isolate_state is not redirecting diet_guard._state."
|
||||
"FOOD_LOG_FILE -- tests would write to the real food log"
|
||||
)
|
||||
|
||||
_state._write_log({"2026-06-22": []})
|
||||
assert _state.FOOD_LOG_FILE.exists(), "write did not land on the redirect"
|
||||
179
diet_guard/tests/test_state_sync.py
Normal file
179
diet_guard/tests/test_state_sync.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""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 diet_guard import _state, _state_sync, _state_today
|
||||
from diet_guard._estimator import Nutrition
|
||||
from diet_guard._state import (
|
||||
load_log,
|
||||
log_meal,
|
||||
)
|
||||
from diet_guard._state_sync import (
|
||||
read_raw_log,
|
||||
resign_entry,
|
||||
undo_last_today,
|
||||
write_raw_log,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
logged_slots_today,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
)
|
||||
|
||||
|
||||
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 TestUndo:
|
||||
"""Tombstoning 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 tombstones 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_tombstones_in_place(self) -> None:
|
||||
"""Undoing the only entry keeps it on disk, marked deleted."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
assert len(raw[day]) == 1
|
||||
assert raw[day][0]["deleted"] is True
|
||||
|
||||
def test_undo_tombstone_excluded_from_reads(self) -> None:
|
||||
"""A tombstoned entry no longer counts toward totals or slots."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert today_total_kcal() == 0.0
|
||||
assert today_entries() == []
|
||||
assert logged_slots_today() == set()
|
||||
|
||||
def test_undo_re_signs_the_tombstone(self) -> None:
|
||||
"""The mutated (tombstoned) entry still carries a valid signature."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
assert "hmac" in raw[day][0]
|
||||
|
||||
def test_undo_unsigned_when_no_key(self) -> None:
|
||||
"""Re-signing a tombstone with no key available leaves it unsigned."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
|
||||
undo_last_today()
|
||||
raw = _raw()
|
||||
day = next(iter(raw))
|
||||
assert "hmac" not in raw[day][0]
|
||||
|
||||
def test_undo_skips_already_tombstoned(self) -> None:
|
||||
"""Undoing twice tombstones the prior entry, not the same one again."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
log_meal("b", _nut(200), slot=12)
|
||||
undo_last_today()
|
||||
second = undo_last_today()
|
||||
assert second is not None
|
||||
assert second["desc"] == "a"
|
||||
|
||||
def test_undo_nothing_left_once_all_tombstoned(self) -> None:
|
||||
"""Once every entry today is tombstoned, undo returns None."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert undo_last_today() is None
|
||||
|
||||
|
||||
class TestLoadLogSkipsTombstones:
|
||||
"""``load_log`` filters out deleted entries the same way as invalid ones."""
|
||||
|
||||
def test_day_with_only_a_tombstone_is_omitted(self) -> None:
|
||||
"""A day whose sole entry is tombstoned is dropped entirely."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
assert load_log() == {}
|
||||
|
||||
|
||||
class TestRawLogAccess:
|
||||
"""Public raw read/write, used by the sync orchestration."""
|
||||
|
||||
def test_read_raw_log_includes_tombstones(self) -> None:
|
||||
"""Unlike load_log, read_raw_log keeps a tombstoned entry."""
|
||||
log_meal("a", _nut(100), slot=8)
|
||||
undo_last_today()
|
||||
raw = read_raw_log()
|
||||
day = next(iter(raw))
|
||||
assert raw[day][0]["deleted"] is True
|
||||
|
||||
def test_write_raw_log_roundtrips(self) -> None:
|
||||
"""write_raw_log persists exactly what read_raw_log later returns."""
|
||||
log = {"2026-06-22": [{"id": "x", "time": "2026-06-22T08:00:00+02:00"}]}
|
||||
write_raw_log(log)
|
||||
assert read_raw_log() == log
|
||||
|
||||
def test_write_leaves_no_temp_file(self) -> None:
|
||||
"""A successful atomic write cleans up its temp file."""
|
||||
write_raw_log({"2026-06-22": [{"id": "x"}]})
|
||||
assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == []
|
||||
|
||||
def test_write_failure_preserves_prior_log(self) -> None:
|
||||
"""A failed replace leaves the old log intact and no temp behind.
|
||||
|
||||
This is the point of the atomic write: a concurrent reader (the gate
|
||||
now syncs while the timer may also write) never sees a torn or empty
|
||||
log just because a write was interrupted.
|
||||
"""
|
||||
write_raw_log({"2026-06-22": [{"id": "original"}]})
|
||||
with (
|
||||
patch("pathlib.Path.replace", side_effect=OSError("no space")),
|
||||
pytest.raises(OSError, match="no space"),
|
||||
):
|
||||
write_raw_log({"2026-06-22": [{"id": "clobbered"}]})
|
||||
assert read_raw_log() == {"2026-06-22": [{"id": "original"}]}
|
||||
assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == []
|
||||
|
||||
|
||||
class TestResignEntry:
|
||||
"""resign_entry recomputes the hmac so a merged entry validates again."""
|
||||
|
||||
def test_strips_and_recomputes_signature(self) -> None:
|
||||
"""A re-signed entry's hmac changes but verifies against the key."""
|
||||
entry = log_meal("a", _nut(100), slot=8)
|
||||
tampered = dict(entry, kcal=999.0)
|
||||
resigned = resign_entry(tampered)
|
||||
assert resigned["hmac"] != entry["hmac"]
|
||||
write_raw_log({"2026-06-22": [resigned]})
|
||||
with patch.object(_state_today, "_today", return_value="2026-06-22"):
|
||||
assert today_entries() == [resigned]
|
||||
|
||||
def test_no_op_signature_wise_when_no_key_available(self) -> None:
|
||||
"""Without an HMAC key, resign_entry produces no hmac field."""
|
||||
entry = log_meal("a", _nut(100), slot=8)
|
||||
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
|
||||
resigned = resign_entry(entry)
|
||||
assert "hmac" not in resigned
|
||||
88
diet_guard/tests/test_state_today.py
Normal file
88
diet_guard/tests/test_state_today.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""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
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard import _state
|
||||
from diet_guard._budget import BudgetNotInitializedError, write_budget
|
||||
from diet_guard._estimator import Nutrition
|
||||
from diet_guard._state import (
|
||||
log_meal,
|
||||
)
|
||||
from diet_guard._state_today import (
|
||||
consumption_band,
|
||||
logged_slots_today,
|
||||
remaining_budget,
|
||||
)
|
||||
|
||||
|
||||
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 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 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."""
|
||||
write_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'."""
|
||||
write_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'."""
|
||||
write_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'."""
|
||||
write_budget(2000)
|
||||
log_meal("a", _nut(2100), slot=8)
|
||||
assert consumption_band() == "OVER BUDGET"
|
||||
Loading…
Reference in New Issue
Block a user