diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 69dc7a2..8a1f3e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8d224a2..e6f5fce 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index a326036..3a019b2 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -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: diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py index 4021134..1f415a2 100644 --- a/diet_guard/_gate.py +++ b/diet_guard/_gate.py @@ -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 diff --git a/diet_guard/_gatelock_dashboard.py b/diet_guard/_gatelock_dashboard.py index cdd618a..fe2cdda 100644 --- a/diet_guard/_gatelock_dashboard.py +++ b/diet_guard/_gatelock_dashboard.py @@ -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, diff --git a/diet_guard/_gatelock_nutrition.py b/diet_guard/_gatelock_nutrition.py index 88c23ec..db96d22 100644 --- a/diet_guard/_gatelock_nutrition.py +++ b/diet_guard/_gatelock_nutrition.py @@ -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: diff --git a/diet_guard/_mcp_read.py b/diet_guard/_mcp_read.py index 27d6f13..ad1400c 100644 --- a/diet_guard/_mcp_read.py +++ b/diet_guard/_mcp_read.py @@ -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, diff --git a/diet_guard/_state.py b/diet_guard/_state.py index bcbd8ea..4998497 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -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 diff --git a/diet_guard/_state_sync.py b/diet_guard/_state_sync.py new file mode 100644 index 0000000..2228ce4 --- /dev/null +++ b/diet_guard/_state_sync.py @@ -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 diff --git a/diet_guard/_state_today.py b/diet_guard/_state_today.py new file mode 100644 index 0000000..31dbfbf --- /dev/null +++ b/diet_guard/_state_today.py @@ -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" diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index 4b6231c..fe87a94 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -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 diff --git a/diet_guard/tests/_tk_fakes.py b/diet_guard/tests/_tk_fakes.py index 338d374..41e252e 100644 --- a/diet_guard/tests/_tk_fakes.py +++ b/diet_guard/tests/_tk_fakes.py @@ -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, diff --git a/diet_guard/tests/_tk_fakes_canvas.py b/diet_guard/tests/_tk_fakes_canvas.py new file mode 100644 index 0000000..eb51ba1 --- /dev/null +++ b/diet_guard/tests/_tk_fakes_canvas.py @@ -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 diff --git a/diet_guard/tests/_tk_fakes_containers.py b/diet_guard/tests/_tk_fakes_containers.py new file mode 100644 index 0000000..a7e3f34 --- /dev/null +++ b/diet_guard/tests/_tk_fakes_containers.py @@ -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) diff --git a/diet_guard/tests/test_daystatus.py b/diet_guard/tests/test_daystatus.py index 9cc2761..d5064a7 100644 --- a/diet_guard/tests/test_daystatus.py +++ b/diet_guard/tests/test_daystatus.py @@ -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 ( diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py index 2d514aa..fc8c397 100644 --- a/diet_guard/tests/test_state.py +++ b/diet_guard/tests/test_state.py @@ -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 diff --git a/diet_guard/tests/test_state_redirect.py b/diet_guard/tests/test_state_redirect.py new file mode 100644 index 0000000..eccd799 --- /dev/null +++ b/diet_guard/tests/test_state_redirect.py @@ -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" diff --git a/diet_guard/tests/test_state_sync.py b/diet_guard/tests/test_state_sync.py new file mode 100644 index 0000000..f9d3349 --- /dev/null +++ b/diet_guard/tests/test_state_sync.py @@ -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 diff --git a/diet_guard/tests/test_state_today.py b/diet_guard/tests/test_state_today.py new file mode 100644 index 0000000..6504292 --- /dev/null +++ b/diet_guard/tests/test_state_today.py @@ -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"