diet-guard/diet_guard/tests/test_meal_schedule_store.py
Krzysztof kuhy Rudnicki 2bdb7925a8 Read the meal schedule from a forward-only history (Python)
Wires the Python side to the configurable schedule. No edit surface yet, so
nothing writes a schedule and current_schedule() returns the default -- the
derived hours are still 08/12/16/20 and the cutoff is still 22:00. Verified by
running it: before any edit the slots are unchanged, after recording 08-20 x5
they become 08:00, 11:00, 14:00, 17:00, 20:00, and a 2020 day still resolves
to the four-meal schedule.

_slots.py now takes the schedule as a *required* argument rather than reading
module constants. Required, not defaulted, on purpose: a default would let a
call site missed in this refactor keep deriving the old fixed hours on one
device, and a slot one device offers while the other does not is a checkpoint
that can never be satisfied. Making it required let mypy enumerate the nine
call sites instead of trusting a grep.

The history is forward-only and seeds the default at the epoch *before*
recording today's value, so switching to five meals leaves past days judged
against the four-meal schedule they actually ran under. Same shape and same
reasoning as _budget_history.py; it will ride the same CRDT record.

Two details worth naming:

- MEAL_SCHEDULE_FILE lands in DATA_DIR, so conftest's _isolate_state redirect
  is patched in this same commit rather than a later one. Confirmed with a
  canary that the redirect is live and that ~/.local/share/diet_guard/ has no
  .meal_schedule after a full run.
- _mcp_read imports _budget.current_schedule already, so the meal-schedule
  lookup is aliased rather than shadowing it.

_gatelock._pending_slots is spelled `is None` rather than `or` now. The two are
in fact equivalent -- if 0 is a slot it is necessarily the first slot, checked
exhaustively -- but the intent should not rest on that coincidence.

761 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 14:28:22 +02:00

197 lines
8.3 KiB
Python

"""Tests for the meal-schedule history and its on-disk form.
Mirrors ``test_budget_history*.py``: the two histories exist for the same
reason and share a shape, so a change to one usually wants the same change
here.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from unittest.mock import patch
from diet_guard import _meal_schedule_store as store
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
if TYPE_CHECKING:
from pathlib import Path
def _at(day: str) -> datetime:
"""Return midnight UTC on ``day`` (``YYYY-MM-DD``)."""
return datetime.fromisoformat(f"{day}T00:00:00+00:00")
class TestEntryJson:
"""One entry's wire/disk representation."""
def test_round_trips(self) -> None:
"""Encoding then decoding preserves the schedule and the stamp."""
entry = store.ScheduleEntry(
effective_from="2026-08-16",
schedule=MealSchedule(8, 20, 5),
edited_at="2026-08-16T12:00:00+00:00",
)
assert store.entry_from_json("2026-08-16", store.entry_to_json(entry)) == entry
def test_normalizes_on_the_way_in(self) -> None:
"""A peer's out-of-range schedule is clamped, not trusted verbatim."""
entry = store.entry_from_json("2026-08-16", {"f": 8, "l": 20, "n": 99})
assert entry is not None
assert entry.schedule == MealSchedule(8, 20, 6)
def test_missing_timestamp_falls_back_to_the_epoch(self) -> None:
"""An entry with no stamp still parses, losing only its ordering."""
entry = store.entry_from_json("2026-08-16", {"f": 8, "l": 20, "n": 4})
assert entry is not None
assert entry.edited_at == "1970-01-01T00:00:00+00:00"
def test_rejects_a_non_mapping(self) -> None:
"""A scalar where a record belongs is skipped, not raised on."""
assert store.entry_from_json("2026-08-16", "nonsense") is None
def test_rejects_non_integer_fields(self) -> None:
"""A malformed field skips just that entry."""
assert store.entry_from_json("2026-08-16", {"f": "8", "l": 20, "n": 4}) is None
class TestHistoryJson:
"""The whole-document form."""
def test_round_trips_and_sorts(self) -> None:
"""Entries come back ascending regardless of insertion order."""
entries = (
store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"),
store.ScheduleEntry("2026-01-01", DEFAULT_SCHEDULE, "t0"),
)
parsed = store.history_from_json(store.history_to_json(entries))
assert [entry.effective_from for entry in parsed] == [
"2026-01-01",
"2026-08-16",
]
def test_unreadable_documents_yield_nothing(self) -> None:
"""Anything unusable degrades to "no history", never an exception."""
assert store.history_from_json("nonsense") == ()
assert store.history_from_json({"e": "nonsense"}) == ()
assert store.history_from_json({}) == ()
def test_skips_only_the_bad_entry(self) -> None:
"""One corrupt field from a peer cannot take out the whole history."""
parsed = store.history_from_json(
{"e": {"2026-01-01": {"f": 8, "l": 20, "n": 4}, "2026-02-01": 7}}
)
assert [entry.effective_from for entry in parsed] == ["2026-01-01"]
class TestScheduleForDay:
"""Resolving the schedule that applied on a given day."""
def test_defaults_when_the_history_is_silent(self) -> None:
"""A day before any entry uses the default, not the newest entry."""
entries = (store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t"),)
assert store.schedule_for_day(entries, "2026-08-15") == DEFAULT_SCHEDULE
def test_uses_the_newest_applicable_entry(self) -> None:
"""The latest entry effective on or before the day wins."""
entries = (
store.ScheduleEntry("2026-01-01", MealSchedule(8, 20, 4), "t0"),
store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"),
)
assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(8, 20, 5)
assert store.schedule_for_day(entries, "2026-05-01") == MealSchedule(8, 20, 4)
class TestUpsert:
"""Appending an edit to the history."""
def test_appends_a_new_day(self) -> None:
"""An edit on a fresh day adds an entry."""
entries = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16"))
assert len(entries) == 1
assert entries[0].effective_from == "2026-08-16"
def test_replaces_a_same_day_re_edit(self) -> None:
"""Editing twice in one day leaves one entry, not two."""
first = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16"))
second = store.upsert(first, MealSchedule(9, 21, 3), _at("2026-08-16"))
assert len(second) == 1
assert second[0].schedule == MealSchedule(9, 21, 3)
class TestSeedDefault:
"""Grandfathering days that predate the first edit."""
def test_pins_the_default_at_the_epoch(self) -> None:
"""Seeding makes every earlier day resolve to the old schedule."""
seeded = store.seed_default(())
assert seeded[0].effective_from == store.EPOCH_DAY
assert seeded[0].schedule == DEFAULT_SCHEDULE
def test_is_idempotent(self) -> None:
"""Seeding twice does not stack a second epoch entry."""
assert store.seed_default(store.seed_default(())) == store.seed_default(())
class TestPersistence:
"""The on-disk file, through the conftest-redirected path."""
def test_absent_file_reads_as_no_history(self) -> None:
"""A device that has never edited contributes nothing."""
assert store.read_raw_history() is None
assert store.load_entries() == ()
def test_round_trips_through_disk(self) -> None:
"""What is written is what is read back."""
store.record_schedule_change(MealSchedule(8, 20, 5), when=_at("2026-08-16"))
assert store.schedule_for_day(
store.load_entries(), "2026-08-16"
) == MealSchedule(8, 20, 5)
def test_recording_grandfathers_earlier_days(self) -> None:
"""Switching to five meals leaves past days on the four-meal schedule.
This is the whole point of the history: without the epoch seed, every
past day would adopt the schedule chosen today and look like it had
missed a checkpoint.
"""
store.record_schedule_change(MealSchedule(8, 20, 5), when=_at("2026-08-16"))
entries = store.load_entries()
assert store.schedule_for_day(entries, "2020-01-01") == DEFAULT_SCHEDULE
assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(8, 20, 5)
def test_corrupt_file_degrades_to_the_default(self) -> None:
"""Unparsable JSON is treated as "no history", never a crash."""
store.MEAL_SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
store.MEAL_SCHEDULE_FILE.write_text("{not json")
assert store.read_raw_history() is None
assert store.current_schedule() == DEFAULT_SCHEDULE
def test_non_mapping_document_degrades(self) -> None:
"""A JSON array where an object belongs is ignored."""
store.MEAL_SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
store.MEAL_SCHEDULE_FILE.write_text("[1, 2]")
assert store.read_raw_history() is None
def test_unreadable_file_degrades(self, tmp_path: Path) -> None:
"""An OSError on read is swallowed like a parse failure."""
directory = tmp_path / "not-a-file"
directory.mkdir()
# Patched rather than assigned: a bare assignment would outlive this
# test and silently redirect the module for everything after it.
with patch.object(store, "MEAL_SCHEDULE_FILE", directory):
assert store.read_raw_history() is None
def test_current_schedule_defaults_before_any_edit(self) -> None:
"""A fresh install behaves exactly as it did before this feature."""
assert store.current_schedule() == DEFAULT_SCHEDULE
def test_record_defaults_to_now(self) -> None:
"""Omitting ``when`` stamps the edit with the current time."""
store.record_schedule_change(MealSchedule(7, 19, 3))
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
assert store.schedule_for_day(store.load_entries(), today) == MealSchedule(
7, 19, 3
)