diet-guard/scripts/meal_schedule_cases.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

126 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Input cases for ``scripts/build_meal_schedule_fixture.py``.
Split out of the builder for the repo's 250-line cap. Inputs only: every
expected value is computed by the builder from the Python implementation.
A script-style file like its siblings (hence the shebang); running it
directly does nothing -- run the builder.
"""
from __future__ import annotations
H = 60
Triple = tuple[int, int, int]
def window(first: int, last: int) -> list[Triple]:
"""Return ``(first, last, count)`` for counts 2..6 plus a clamp either side."""
return [(first, last, count) for count in (0, 2, 3, 4, 5, 6, 7)]
#: Curated (first_minute, last_minute, count) inputs. Both suites also sweep
#: the 15-minute grid in code; this table holds exact values a human can check.
SCHEDULES: list[Triple] = [
*window(8 * H, 20 * H), # the default window
*window(7 * H + 15, 19 * H), # the user's quarter-hour breakfast
*window(7 * H + 23, 19 * H + 41), # off-grid endpoints, never snapped
*window(0, 1439), # the whole day, last at 23:59
(7 * H, 19 * H, 5),
(7 * H, 21 * H, 4), # uneven: the old hour formula said 12:00 here
(7 * H, 21 * H, 5),
(9 * H, 19 * H, 4),
(0, 12 * H, 3), # a midnight first slot is a real slot
(0, H, 2),
(0, H, 6),
(22 * H, 1439, 3),
(23 * H, 1439, 6),
# Narrow windows: count is capped at window // 15 + 1.
(8 * H, 8 * H + 15, 6),
(8 * H, 8 * H + 30, 6),
(8 * H, 8 * H + 45, 6),
(8 * H, 9 * H, 6),
(8 * H, 9 * H + 14, 6),
(8 * H, 9 * H + 15, 6),
(481, 496, 3),
(8 * H + 7, 9 * H + 22, 6),
# Every normalized() clamp.
(-5, 20 * H, 4),
(1430, 1439, 2),
(1439, 1439, 2),
(2000, -5, 999),
(8 * H, 5000, 4),
(12 * H, 12 * H, 4),
(12 * H, 100, 4),
(12 * H, 12 * H + 14, 2),
(8 * H, 20 * H, -3),
(8 * H, 20 * H, 99),
(8, 20, 4), # an hour-valued caller that missed the rename: 00:08-00:23
]
#: Schedules whose every slot +/- 1 minute and cutoff edge is pinned.
EDGE_SCHEDULES: list[Triple] = [
(8 * H, 20 * H, 4),
(8 * H, 20 * H, 5),
(7 * H + 15, 19 * H, 5),
(7 * H + 23, 19 * H + 41, 4),
(0, 12 * H, 3),
(0, 1439, 6), # cutoff clamps to 1440: the window closes at midnight
(22 * H, 1439, 3),
]
DEFAULT: Triple = (8 * H, 20 * H, 4)
SATISFIED: list[tuple[str, Triple, list[int]]] = [
("empty input satisfies nothing", DEFAULT, []),
# Logged 07:00 and 10:00 under 07:00-19:00 x5, then the schedule moved.
("old 07:00/10:00 meals after moving to 07:15", (435, 1140, 5), [420, 600]),
("two meals snap onto one slot", DEFAULT, [480, 540]),
("legacy hour entries under five meals", (8 * H, 20 * H, 5), [480, 720]),
("exact halfway goes to the earlier slot", DEFAULT, [600, 840]),
("out-of-window minutes clamp to the ends", DEFAULT, [0, 1439]),
("every slot exactly", DEFAULT, [480, 720, 960, 1200]),
]
#: (schedule, minute, logged slot minutes) for missing_slots.
MISSING: list[tuple[Triple, int, list[int]]] = [
(DEFAULT, 13 * H, [480, 720]),
(DEFAULT, 17 * H, [480]),
(DEFAULT, 23 * H, []),
((435, 1140, 5), 615, [435]),
((435, 1140, 5), 614, []),
]
LABELS = [0, 5, 59, 60, 435, 480, 615, 1200, 1439, 1440, -15]
WIRE_DECODE: list[object] = [
{"f": 8, "l": 20, "n": 4},
{"f": 7, "l": 19, "n": 5, "fm": 435},
{"f": 7, "l": 19, "n": 5, "fm": 443, "lm": 1181},
{"f": 7, "l": 19, "n": 5, "fm": 600}, # fm wins even if it disagrees with f
{"f": 7, "l": 19, "n": 5, "fm": True}, # bool is not an int: f wins
{"f": 7, "l": 19, "n": 5, "fm": 435.0}, # nor is a float
{"f": 7, "l": 19, "n": 5, "lm": None},
{"f": True, "l": 20, "n": 4},
{"f": 8, "l": 20},
{"f": "8", "l": 20, "n": 4},
{"f": 99, "l": -5, "n": 999}, # garbage from a peer is normalised
[8, 20, 4],
None,
]
SLOT_FIELD_MINUTES = [0, 59, 60, 435, 480, 1200, 1439, -1, 1440]
ENTRIES: list[dict[str, object]] = [
{"slot": 8},
{"slot": 0},
{"slot": 7, "slot_min": 435},
{"slot_min": 435},
{"slot": 5, "slot_min": 0}, # a zero slot_min still wins
{"slot": 8, "slot_min": True},
{"slot": 8, "slot_min": False},
{"slot": True},
{"slot": "8"},
{"slot": 8.0},
{"slot": None},
{},
]