diet-guard/scripts/build_kuchnia_fixture.py
Krzysztof kuhy Rudnicki 8bc1812147 slots: minute-of-day contract (phase 1 of 3: pure model + shared fixture)
Meal-slot checkpoints move from whole hours to minute-of-day ints
(0..1439) in the pure model and slot arithmetic of both languages:
MealSchedule(first_minute, last_minute, count) / (firstMinute:,
lastMinute:, count:), interior slots snapped to the 15-minute grid,
endpoints exact, integer-only. New: nearest_slot, satisfied_slots,
slot_fields / entry_slot_minute (additive "slot_min" wire field) and
schedule_to_wire / schedule_from_wire ("fm"/"lm" only when off the hour,
so whole-hour data stays byte-identical), parse_hhmm.

tests/fixtures/meal_schedule_vectors.json (scripts/
build_meal_schedule_fixture.py) is read by both suites; kuchnia_day.json
slot values regenerated as minutes.

Consumers (CLI, gate, storage, sync, app screens, kuchnia, notifications)
are intentionally broken until phase 2. patch-targets skipped: it fails
only because diet_guard._gatelock no longer imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:22:43 +02:00

251 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Regenerate the shared cross-language catering parity fixture.
The fixture (``tests/fixtures/kuchnia_day.json``) is read by *both*
``diet_guard/tests/test_kuchnia_parity.py`` and
``app/test/kuchnia_parity_test.dart``. Two independently written suites from
the same prose is not a gate; one shared input with one shared expected result
is.
``expected`` is generated from the Python implementation, which is the
reference the Dart port must reproduce. That means regenerating after a
behaviour change will happily bless the new behaviour -- so only run this when
the change is intended, and re-read the diff before committing it.
The five real dishes live in ``tests/fixtures/kuchnia_real_meals.json``: a
scrubbed capture of the live panel on 2026-08-22, where the fields the parser
reads are verbatim, opaque ids are replaced with synthetic ones, and
image/allergen/review metadata is dropped. Every other meal is synthetic and
pins exactly one drop or fallback path.
Run it from the repo root, where the editable install puts ``diet_guard`` on
the path -- the same assumption the test suite makes. Both forms work:
python3 scripts/build_kuchnia_fixture.py
python3 -m scripts.build_kuchnia_fixture
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
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
FIXTURE = Path(__file__).resolve().parent.parent / "tests/fixtures/kuchnia_day.json"
#: The real, scrubbed dishes, kept as a sidecar data file rather than a literal
#: here: transcribing them by hand silently corrupted three of the five (wrong
#: macros, truncated names) and the mistake was invisible in review.
#:
#: Diacritics are deliberately preserved. The two devices normalize bank keys
#: with different primitives (Python ``str.casefold()``, Dart
#: ``String.toLowerCase()``) and this is what pins their agreement across the
#: Polish alphabet.
REAL_MEALS_FILE = FIXTURE.parent / "kuchnia_real_meals.json"
def _real_meals() -> list[dict[str, object]]:
"""Load the scrubbed capture of the live panel on 2026-08-22."""
with REAL_MEALS_FILE.open(encoding="utf-8") as handle:
return json.load(handle)
def _nutrition(
weight: float, kcal: object, protein: float, carbs: float, fat: float
) -> dict[str, object]:
"""Build a minimal ``nutrition`` block for a synthetic meal."""
return {
"weight": weight,
"calories": kcal,
"protein": protein,
"carbohydrate": carbs,
"fat": fat,
}
#: One synthetic meal per parser branch. Without these, "dropped dishes" is a
#: vacuous assertion -- a real capture is all happy path.
_SYNTHETIC_MEALS: list[object] = [
# Macros quoted per 100 g against a per-portion kcal: the one failure mode
# that would silently skew every logged meal by a factor of several.
{
"mealName": "Obiad",
"menuMealName": "Per-100g mix-up, dropped",
"mealPriority": 6,
"nutrition": _nutrition(400.0, 900.0, 5.0, 6.0, 2.0),
},
# Portion outside the sane gram band (kg mistaken for g).
{
"mealName": "Kolacja",
"menuMealName": "Absurd portion, dropped",
"mealPriority": 7,
"nutrition": _nutrition(9000.0, 500.0, 30.0, 60.0, 15.0),
},
# No mealPriority -> falls back to 1-based payload position.
{
"mealName": "Przekąska",
"menuMealName": "No priority, kept",
"nutrition": _nutrition(120.0, 300.0, 10.0, 40.0, 8.0),
},
# A numeric field delivered as a JSON string. ``as_float`` rejects strings
# (0.0), so the energy check fails and the dish drops. Pins the coercion
# contract: Dart must NOT `double.tryParse` its way to a different answer.
{
"mealName": "Obiad",
"menuMealName": "Stringly typed, dropped",
"mealPriority": 8,
"nutrition": _nutrition(200.0, "435", 25.0, 50.0, 12.0),
},
# Not a dict at all.
"not-a-meal",
# ``nutrition`` absent entirely.
{
"mealName": "Obiad",
"menuMealName": "No nutrition, dropped",
"mealPriority": 9,
},
# Two dishes sharing BOTH priority and name. Python's ``sorted`` is stable
# and Dart's ``List.sort`` is not, so this pair is what forces both sides to
# make the comparator total.
{
"mealName": "Obiad",
"menuMealName": "Twin dish",
"mealPriority": 5,
"nutrition": _nutrition(300.0, 400.0, 25.0, 45.0, 12.0),
},
{
"mealName": "Obiad",
"menuMealName": "Twin dish",
"mealPriority": 5,
"nutrition": _nutrition(300.0, 400.0, 25.0, 45.0, 12.0),
},
]
def _dish_json(dish: Dish) -> dict[str, object]:
"""Serialize a ``Dish`` field-for-field for the expected block."""
return {
"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,
}
#: "Fill all with catering" cases. ``take`` indexes the parsed ``dishes`` list,
#: re-spread over ``slots`` (never a slice of the full spread). The full day
#: spreads the twins to 16 and 20, so ``twins_share_16`` drops the first dish:
#: 7 on 4 slots puts both twins on 16, the one place intra-batch dedup bites.
#: Slots are MINUTES OF DAY, ``occupied``/``today[].slot`` too: resolved slot
#: minutes, not the on-disk hour (seed real entries via ``slot_fields``).
_ALL = list(range(8))
_SLOTS = [480, 720, 960, 1200]
_PLAN_CASES = {
"empty_day": {"take": _ALL, "occupied": []},
"occupied_12": {"take": _ALL, "occupied": [720]},
# First five in slot order: pancakes and hummus double into 08:00, both kept.
"five_on_four": {"take": _ALL[:5], "occupied": [960]},
"full_day": {"take": _ALL, "occupied": _SLOTS},
}
# Case/space-insensitive name match, and the same name in another slot is no
# match: log_dishes dedups by (name, slot), it does not test occupancy.
_TWIN_SEEN = {"desc": " twin DISH ", "slot": 960}
_SEEN = [_TWIN_SEEN, {"desc": "No priority, kept", "slot": 480}]
_LOG_CASES = {
"empty_day": {"take": _ALL, "today": []},
"twins_share_16": {"take": _ALL[1:], "today": []},
"twin_logged": {"take": _ALL[1:], "today": _SEEN},
}
def _spread(dishes: list[Dish], case: dict[str, Any]) -> list[SlottedDish]:
"""Assign the case's dish subset to the default slots."""
return assign_slots([dishes[i] for i in case["take"]], _SLOTS)
def _fill_cases(dishes: list[Dish]) -> dict[str, object]:
"""Run the Python ``fill_plan``/``log_dishes`` over every case.
Both seams are patched: unpatched, ``log_meal`` writes to the live log.
"""
plans: dict[str, object] = {}
for key, case in _PLAN_CASES.items():
today = [{"slot": slot} for slot in case["occupied"]]
with patch.object(_kuchnia_log, "today_entries", return_value=today):
kept = _kuchnia_log.fill_plan(_spread(dishes, case))
plans[key] = {**case, "expected": [[s.dish.name, s.slot] for s in kept]}
logs: dict[str, object] = {}
for key, case in _LOG_CASES.items():
with (
patch.object(_kuchnia_log, "today_entries", return_value=case["today"]),
patch.object(_kuchnia_log, "log_meal") as fake,
):
_kuchnia_log.log_dishes(_spread(dishes, case))
written = [[call.args[0], call.args[2]] for call in fake.call_args_list]
logs[key] = {**case, "expected": written}
return {"slots": _SLOTS, "fill_plan": plans, "log_dishes": logs}
def build() -> dict[str, object]:
"""Return the whole fixture: the payload and its expected results."""
payload = {
"deliveryMenuMeal": [*_real_meals(), *_SYNTHETIC_MEALS],
"menuVisible": True,
"showNutrition": True,
}
dishes = parse_menu(payload)
spreads = { # slot-minute keys, comma-joined; both tests parse them back
",".join(map(str, slots)): [s.slot for s in assign_slots(dishes, slots)]
for slots in (_SLOTS, (*_SLOTS, 1320))
}
return {
"_comment": (
"Shared parity fixture for the Kuchnia Wikinga catering import. "
"Read by BOTH diet_guard/tests/test_kuchnia_parity.py and "
"app/test/kuchnia_parity_test.dart -- same input, same expected "
"result. Regenerate with scripts/build_kuchnia_fixture.py."
),
"payload": payload,
"expected": {
"dishes": [_dish_json(dish) for dish in dishes],
"dropped_count": len(payload["deliveryMenuMeal"]) - len(dishes),
"slots": {
**spreads,
# The other direction: fewer dishes than slots.
"first_three_480,720,960,1200": [
s.slot for s in assign_slots(dishes[:3], _SLOTS)
],
},
"slot_order": [s.dish.name for s in assign_slots(dishes, _SLOTS)],
"bank_keys": [dish.name.strip().casefold() for dish in dishes],
"bank_records": [dish_to_record(dish) for dish in dishes],
"fill": _fill_cases(dishes),
},
}
def main() -> None:
"""Write the fixture to disk.
Deliberately silent: ``T201`` bans ``print`` here, and the script's output
is the file itself -- ``git diff`` is how you check what changed.
"""
FIXTURE.parent.mkdir(parents=True, exist_ok=True)
with FIXTURE.open("w", encoding="utf-8") as handle:
json.dump(build(), handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
if __name__ == "__main__":
main()