mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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
251 lines
9.7 KiB
Python
Executable File
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()
|