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:
Krzysztof Rudnicki 2026-08-15 20:21:26 +02:00
parent 8f2d1011c7
commit af461d3157
19 changed files with 933 additions and 607 deletions

View File

@ -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]

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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:

View File

@ -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,

View File

@ -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
View 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
View 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"

View File

@ -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

View File

@ -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,

View 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

View 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)

View File

@ -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 (

View File

@ -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

View 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"

View 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

View 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"