mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 17:33:55 +01:00
- The popdown maps beneath the gatelock surface yet holds ttk's grab (verified on Xvfb: invisible, swallows the next click, steals keys), so arrow clicks are intercepted as Down already was. Typing and Up/Down stay. - Catering dedup compares the entry's recorded minute (parity with the app); occupancy (fill_plan) still snaps. - Test: a corrupt peer slot (slot 24, slot_min 5000) re-signs and reads without raising. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
209 lines
8.0 KiB
Python
209 lines
8.0 KiB
Python
"""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=480)
|
|
log_meal("b", _nut(200), slot=720)
|
|
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=480)
|
|
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=480)
|
|
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=480)
|
|
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=480)
|
|
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=480)
|
|
log_meal("b", _nut(200), slot=720)
|
|
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=480)
|
|
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=480)
|
|
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=480)
|
|
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=480)
|
|
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_an_off_hour_slot_survives_a_resign_and_still_verifies(self) -> None:
|
|
"""``slot_min`` is covered by the new signature, not dropped by it.
|
|
|
|
The PC re-signs every merged entry; losing ``slot_min`` there would
|
|
quietly turn a phone-logged 07:15 meal back into a 07:00 one.
|
|
"""
|
|
entry = log_meal("a", _nut(100), slot=435)
|
|
assert (entry["slot"], entry["slot_min"]) == (7, 435)
|
|
resigned = resign_entry(entry)
|
|
assert resigned["slot_min"] == 435
|
|
write_raw_log({"2026-06-22": [resigned]})
|
|
with patch.object(_state_today, "_today", return_value="2026-06-22"):
|
|
assert today_entries() == [resigned]
|
|
|
|
def test_a_corrupt_peer_slot_is_relayed_not_raised_on(self) -> None:
|
|
"""Out-of-range peer values pass through re-signing and reading.
|
|
|
|
Only our own writers encode through ``slot_fields`` (which refuses an
|
|
out-of-range minute); a peer's ``slot: 24`` must ride through the
|
|
merge path unchanged -- raising here would stall every sync tick.
|
|
"""
|
|
peer = {"id": "p", "time": "2026-06-22T09:00:00+02:00", "kcal": 1.0}
|
|
resigned = resign_entry({**peer, "slot": 24, "slot_min": 5000})
|
|
assert (resigned["slot"], resigned["slot_min"]) == (24, 5000)
|
|
write_raw_log({"2026-06-22": [resigned]})
|
|
with patch.object(_state_today, "_today", return_value="2026-06-22"):
|
|
# 5000 snaps to the last slot rather than raising.
|
|
assert logged_slots_today() == {1200}
|
|
|
|
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=480)
|
|
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
|
|
resigned = resign_entry(entry)
|
|
assert "hmac" not in resigned
|