mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Every Python consumer now speaks slot minutes:
- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
at every "is this slot logged?" site: gate, CLI status, MCP, catering
occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
legacy int hour, validated against today's slots; get_slots returns
{minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
list, typed times, Up/Down step without posting the popdown), locked
as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
217 lines
9.1 KiB
Python
217 lines
9.1 KiB
Python
"""The Python half of the cross-language catering parity gate.
|
|
|
|
``app/test/kuchnia_parity_test.dart`` asserts the *same* expectations against
|
|
the *same* ``tests/fixtures/kuchnia_day.json``. Two independently written
|
|
suites from the same prose is not a gate -- one shared input with one shared
|
|
expected result is, because a divergence has to show up as a failure on one
|
|
side rather than as two self-consistent implementations.
|
|
|
|
What the parity actually protects, per ``docs/kuchnia-wikinga.md``:
|
|
|
|
* **Slot assignment.** A slot one device offers while the other does not is a
|
|
checkpoint that can never be satisfied -- a permanent lock.
|
|
* **Which dishes are dropped.** If the two sides disagree, each re-adds what
|
|
the other dropped, ``add_manual_entry`` restamps ``t`` unconditionally, and
|
|
the curated bank republishes to every peer on every refresh.
|
|
* **Bank keys and record values.** Divergence there is the same flood by a
|
|
different route.
|
|
* **Fill all with catering.** Which dishes ``fill_plan`` keeps and which
|
|
``log_dishes`` writes, so both devices fill the same empty slots.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from diet_guard import _kuchnia_log
|
|
from diet_guard._kuchnia_import import dish_to_record
|
|
from diet_guard._kuchnia_parse import Dish, parse_menu
|
|
from diet_guard._kuchnia_spread import SlottedDish, assign_slots
|
|
from diet_guard._slot_wire import slot_fields
|
|
|
|
FIXTURE = Path(__file__).resolve().parents[2] / "tests/fixtures/kuchnia_day.json"
|
|
|
|
|
|
@pytest.fixture(name="fixture")
|
|
def _fixture() -> dict[str, Any]:
|
|
"""The shared payload/expected pair, read from the committed JSON.
|
|
|
|
Typed ``Any`` deliberately: this is decoded JSON whose nested shape is the
|
|
fixture's own contract, and every read below is an assertion about that
|
|
shape. Threading precise types through would restate the fixture's schema
|
|
in the type system without making a divergence any more visible.
|
|
"""
|
|
with FIXTURE.open(encoding="utf-8") as handle:
|
|
data: dict[str, Any] = json.load(handle)
|
|
return data
|
|
|
|
|
|
@pytest.fixture(name="dishes")
|
|
def _dishes(fixture: dict[str, Any]) -> list[Dish]:
|
|
"""The dishes the parser extracts from the shared payload."""
|
|
return parse_menu(fixture["payload"])
|
|
|
|
|
|
def _slots_for(key: str) -> tuple[int, ...]:
|
|
"""Turn a fixture slot key such as ``"480,720,960,1200"`` into slot minutes."""
|
|
return tuple(int(part) for part in key.split(","))
|
|
|
|
|
|
def test_fixture_is_present() -> None:
|
|
"""The fixture is shared with ``flutter test``; a move breaks both silently.
|
|
|
|
Asserted explicitly so a relocation fails here with a clear reason rather
|
|
than as a confusing KeyError inside another test.
|
|
"""
|
|
assert FIXTURE.is_file(), f"shared parity fixture missing at {FIXTURE}"
|
|
|
|
|
|
def test_parsed_dishes_match_expected(
|
|
fixture: dict[str, Any], dishes: list[Dish]
|
|
) -> None:
|
|
"""Every kept dish matches the shared expectation field for field."""
|
|
expected = fixture["expected"]["dishes"]
|
|
actual = [
|
|
{
|
|
"name": dish.name,
|
|
"kcal": dish.kcal,
|
|
"protein_g": dish.protein_g,
|
|
"carbs_g": dish.carbs_g,
|
|
"fat_g": dish.fat_g,
|
|
"grams": dish.grams,
|
|
"priority": dish.priority,
|
|
"slot_label": dish.slot_label,
|
|
}
|
|
for dish in dishes
|
|
]
|
|
assert actual == expected
|
|
|
|
|
|
def test_dropped_count_matches(fixture: dict[str, Any], dishes: list[Dish]) -> None:
|
|
"""The same meals are refused on both sides.
|
|
|
|
A vacuous assertion against a clean capture, which is why the fixture
|
|
carries a per-100 g mix-up, an absurd portion, a stringly-typed number, a
|
|
non-dict entry and a meal with no nutrition at all.
|
|
"""
|
|
total = len(fixture["payload"]["deliveryMenuMeal"])
|
|
assert total - len(dishes) == fixture["expected"]["dropped_count"]
|
|
assert fixture["expected"]["dropped_count"] > 0
|
|
|
|
|
|
def test_slot_assignment_matches(fixture: dict[str, Any], dishes: list[Dish]) -> None:
|
|
"""``i * S // N`` lands each dish on the slot the Dart side also picks."""
|
|
for key, expected in fixture["expected"]["slots"].items():
|
|
subject = dishes[:3] if key.startswith("first_three_") else dishes
|
|
minutes = _slots_for(key.removeprefix("first_three_"))
|
|
actual = [item.slot for item in assign_slots(subject, minutes)]
|
|
assert actual == expected, f"slot assignment diverged for {key}"
|
|
|
|
|
|
def test_slot_order_matches(fixture: dict[str, Any], dishes: list[Dish]) -> None:
|
|
"""Ordering is total, so the twin-dish pair cannot reshuffle between runs.
|
|
|
|
Python's ``sorted`` is stable and Dart's ``List.sort`` is not, so two
|
|
dishes sharing both priority and name are the case that forces both
|
|
comparators to be total.
|
|
"""
|
|
ordered = assign_slots(dishes, (480, 720, 960, 1200))
|
|
assert [item.dish.name for item in ordered] == fixture["expected"]["slot_order"]
|
|
|
|
|
|
def test_bank_keys_match(fixture: dict[str, Any], dishes: list[Dish]) -> None:
|
|
"""Both devices key the curated bank identically for these dish names."""
|
|
keys = [dish.name.strip().casefold() for dish in dishes]
|
|
assert keys == fixture["expected"]["bank_keys"]
|
|
|
|
|
|
def test_bank_records_match(fixture: dict[str, Any], dishes: list[Dish]) -> None:
|
|
"""A banked record is value-identical to the one Dart would write."""
|
|
records = [dish_to_record(dish) for dish in dishes]
|
|
assert records == fixture["expected"]["bank_records"]
|
|
|
|
|
|
def test_bank_record_numbers_are_floats(dishes: list[Dish]) -> None:
|
|
"""Macros bank as floats, so Dart must ``.toDouble()`` before encoding.
|
|
|
|
``jsonEncode(435)`` emits ``435`` where Python emits ``435.0``. Left
|
|
unpinned, an int-typed Dart macro produces a byte-different record for the
|
|
same dish, every refresh sees a change, and the curated bank republishes to
|
|
every peer -- the same flood a tolerance mismatch causes.
|
|
"""
|
|
for record in (dish_to_record(dish) for dish in dishes):
|
|
for key in ("kcal", "protein_g", "carbs_g", "fat_g", "grams"):
|
|
assert isinstance(record[key], float), f"{key} must bank as a float"
|
|
# ``count`` is the exception: an int on both sides.
|
|
assert isinstance(record["count"], int)
|
|
assert not isinstance(record["count"], bool)
|
|
|
|
|
|
def _as_entry(row: dict[str, Any]) -> dict[str, Any]:
|
|
"""Turn a fixture row's resolved ``slot`` minute into on-disk entry fields."""
|
|
fields = {key: value for key, value in row.items() if key != "slot"}
|
|
return {**fields, **slot_fields(row["slot"])}
|
|
|
|
|
|
def _spread(
|
|
dishes: list[Dish], fill: dict[str, Any], case: dict[str, Any]
|
|
) -> list[SlottedDish]:
|
|
"""Re-spread the case's dish subset; never a slice of the full spread."""
|
|
return assign_slots([dishes[i] for i in case["take"]], fill["slots"])
|
|
|
|
|
|
def test_fill_plan_matches(
|
|
fixture: dict[str, Any], dishes: list[Dish], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Only dishes whose slot is empty today are proposed, doubles included.
|
|
|
|
Called through the module so a patched ``fill_plan`` is the one exercised.
|
|
"""
|
|
fill = fixture["expected"]["fill"]
|
|
assert fill["fill_plan"], "fixture carries no fill_plan cases"
|
|
for key, case in fill["fill_plan"].items():
|
|
# ``occupied`` holds RESOLVED slot minutes, not on-disk fields: seed
|
|
# them through the writer's encoding, never as a raw ``{"slot": m}``
|
|
# (which the reader would take for an hour, i.e. ``m * 60``).
|
|
today = [slot_fields(slot) for slot in case["occupied"]]
|
|
monkeypatch.setattr(_kuchnia_log, "today_entries", lambda t=today: t)
|
|
kept = _kuchnia_log.fill_plan(_spread(dishes, fill, case))
|
|
actual = [[item.dish.name, item.slot] for item in kept]
|
|
assert actual == case["expected"], f"fill_plan diverged for {key}"
|
|
|
|
|
|
def test_log_dishes_matches(
|
|
fixture: dict[str, Any], dishes: list[Dish], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The same dishes are written, a twin pair in one slot landing once.
|
|
|
|
``log_meal`` is replaced by a recorder, so nothing reaches the log.
|
|
"""
|
|
fill = fixture["expected"]["fill"]
|
|
for key, case in fill["log_dishes"].items():
|
|
recorder = MagicMock()
|
|
today = [_as_entry(row) for row in case["today"]]
|
|
monkeypatch.setattr(_kuchnia_log, "today_entries", lambda t=today: t)
|
|
monkeypatch.setattr(_kuchnia_log, "log_meal", recorder)
|
|
_kuchnia_log.log_dishes(_spread(dishes, fill, case))
|
|
written = [[call.args[0], call.args[2]] for call in recorder.call_args_list]
|
|
assert written == case["expected"], f"log_dishes diverged for {key}"
|
|
|
|
|
|
def test_twin_case_is_not_vacuous(fixture: dict[str, Any], dishes: list[Dish]) -> None:
|
|
"""The intra-batch case really puts two identical dishes in one slot.
|
|
|
|
Without this, a regenerated fixture whose spread separates the twins would
|
|
keep passing while no longer testing the dedup at all.
|
|
"""
|
|
fill = fixture["expected"]["fill"]
|
|
case = fill["log_dishes"]["twins_share_16"]
|
|
spread = [(item.dish.name, item.slot) for item in _spread(dishes, fill, case)]
|
|
assert spread.count(("Twin dish", 960)) == 2
|
|
assert case["expected"].count(["Twin dish", 960]) == 1
|