mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 18:53:54 +01:00
The phone is getting its own Kuchnia Wikinga importer so the feature works with the PC switched off. That turns a risk `_kuchnia_spread.py` called "theoretical" into a live one: two implementations must now agree exactly on which dishes are dropped, which slot each lands on, and the JSON a banked record encodes to. Disagreement means each device re-adds what the other dropped, and since `add_manual_entry` restamps `t` unconditionally, the whole curated bank republishes to every peer on every refresh. A slot mismatch is worse -- a checkpoint one device offers and the other does not can never be satisfied. So the port lands with its gate rather than after it. One shared fixture, `tests/fixtures/kuchnia_day.json`, is asserted by both `pytest` and `flutter test`: same input, same expected dishes, drops, slots, bank keys and records. Two suites written independently from the same prose is not a gate. The fixture is five real dishes (a scrubbed 2026-08-22 capture) plus synthetic meals that each pin one drop or fallback path -- a per-100 g mix-up, an absurd portion, a stringly-typed number, a non-dict entry, a missing `nutrition`, a missing `mealPriority`, and a twin-dish pair. Without those, "the same meals are dropped" is a vacuous assertion against an all-happy-path capture. Both halves were mutation-checked: flipping an expected slot, drop count or record value fails each suite, and flipping `435.0` to `435` fails the byte-identity test while the value comparison still passes, which is why both exist. Three traps the port had to survive, each caught before it shipped: - `_ENERGY_TOLERANCE` is **0.35**. The prose said "~1%", which describes how closely the captured day agreed, not the threshold; porting 0.01 would drop dishes the PC keeps. That paragraph now says so. - `jsonEncode(435)` emits `435` where Python emits `435.0`, so every Dart macro is forced to `double` and a canonical-encoding test pins it. - Dart's `List.sort` is unstable where Python's `sorted` is stable, so the Dart comparator carries the payload index as a final tiebreak. The real dishes live in their own file rather than as a literal in the generator: transcribing them by hand corrupted three of the five, and the error was invisible until the generated fixture was diffed against the capture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
193 lines
7.0 KiB
Python
Executable File
193 lines
7.0 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 diet_guard._kuchnia_import import dish_to_record
|
|
from diet_guard._kuchnia_parse import Dish, parse_menu
|
|
from diet_guard._kuchnia_spread import 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,
|
|
}
|
|
|
|
|
|
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)
|
|
default_slots = (8, 12, 16, 20)
|
|
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": {
|
|
"8,12,16,20": [s.slot for s in assign_slots(dishes, default_slots)],
|
|
"8,12,16,20,22": [
|
|
s.slot for s in assign_slots(dishes, (8, 12, 16, 20, 22))
|
|
],
|
|
# The other direction: fewer dishes than slots.
|
|
"first_three_8,12,16,20": [
|
|
s.slot for s in assign_slots(dishes[:3], default_slots)
|
|
],
|
|
},
|
|
"slot_order": [s.dish.name for s in assign_slots(dishes, default_slots)],
|
|
"bank_keys": [dish.name.strip().casefold() for dish in dishes],
|
|
"bank_records": [dish_to_record(dish) for dish in 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()
|