Merge branch 'worktree-agent-a92471b85d4d17556' into minute-slots

This commit is contained in:
Krzysztof Rudnicki 2026-10-10 20:13:41 +02:00
commit a118120047
59 changed files with 1222 additions and 466 deletions

View File

@ -1,6 +1,6 @@
# CLAUDE.md — diet_guard
A log-to-unlock gate. Every ~30 min `diet-guard-gate.timer` checks whether a
A log-to-unlock gate. Every ~15 min `diet-guard-gate.timer` checks whether a
meal slot (08:00, 12:00, 16:00, 20:00 by default — the schedule is
user-configurable, see below) elapsed unlogged; if so it opens a
fullscreen Tk window that blocks the desktop until the user logs what they
@ -29,7 +29,7 @@ catch-up rule).
## Scheduling
`diet-guard-gate.timer` is wall-clock (`OnCalendar=*-*-* *:00/30:00`,
`diet-guard-gate.timer` is wall-clock (`OnCalendar=*-*-* *:00/15:00`,
`Persistent=true`), not boot-relative — a boot-relative timer collided with
fullscreen games grabbing input. `diet-guard-gate.service` is `Type=oneshot`
and exits 0 immediately when no lock is due. It needs `DISPLAY`/`XAUTHORITY`;
@ -178,13 +178,14 @@ changing either hides the entire local food log).
## The meal schedule
The slot hours are user-configurable: a first meal, a last meal, and how
many meals fall between them, evenly divided. Default `(8, 20, 4)` is the
old hardcoded 08/12/16/20. Four load-bearing rules (integer-only
arithmetic, whole-hour slots, the count clamp, the `last + 2h` cutoff), the
forward-only history, and the two edit surfaces are documented in
[docs/DOCS-meal-schedule.md](docs/DOCS-meal-schedule.md) — read it before touching
`_slots.py` / `slot.dart`.
Slots are **minutes of day**: a first meal and a last meal (any `HH:MM`) and
how many meals fall between them; interior slots round to the 15-min grid.
Default `(480, 1200, 4)` is the old hardcoded 08/12/16/20. The load-bearing
rules (integer-only arithmetic, the additive `slot`+`slot_min` /
`f`/`l`/`n`+`fm`/`lm` wire, the count clamp, the `last + 120 min` cutoff,
nearest-slot satisfaction), the forward-only history, and the two edit
surfaces are in [docs/DOCS-meal-schedule.md](docs/DOCS-meal-schedule.md) —
read it before touching `_slots.py` / `_slot_wire.py` / `slot.dart`.
## Averages (`_averages.py` / `average_service.dart`)

View File

@ -46,7 +46,7 @@ The band boundaries are the calendar's own (at or under budget / up to 20%
over / more than 20% over), so a period and its day cells can never disagree
about what "over" means.
The timer runs the gate automatically every ~30 minutes; no manual
The timer runs the gate automatically every ~15 minutes; no manual
invocation is needed once installed.
## MCP server

View File

@ -7,7 +7,7 @@ Type=oneshot
# DISPLAY mirrors wake-alarm.service: the gate opens a Tk window when a lock
# is due, so without DISPLAY it would crash with "no display name and no
# $DISPLAY" before it could even check. The command self-checks gate_is_due()
# and exits 0 when no lock is needed, so running it every ~30 min is cheap.
# and exits 0 when no lock is needed, so running it every ~15 min is cheap.
# No PYTHONPATH needed: diet_guard is pip-installed (see install.sh / README),
# so /usr/bin/python finds it via user site-packages.
#

View File

@ -2,10 +2,13 @@
Description=Periodically run the Diet Guard gate check
[Timer]
# Check on a WALL-CLOCK schedule (every 30 min, on the hour and half-hour).
# The 5-hour "log a meal" threshold and the 08:00-22:00 eating window are
# enforced inside gate_is_due(), so the timer only needs to fire often enough to
# notice the boundary; 30 min is plenty.
# Check on a WALL-CLOCK schedule (every 15 min: :00, :15, :30, :45).
# The meal slots and the enforcement window are enforced inside gate_is_due(),
# so the timer only needs to fire often enough to notice a slot opening. Slots
# can sit on any quarter hour (interior slots round to the 15-minute grid, and
# a 07:15 first meal is common), so a 30-min tick would let a slot go
# unenforced for up to half an hour; 15 min lands on every grid slot exactly.
# A first/last meal typed off the grid (07:23) is caught by the next tick.
#
# Why OnCalendar and not OnBootSec/OnUnitActiveSec: the gate is a *blocking*
# oneshot -- it stays up until a meal is logged -- and a monotonic schedule
@ -13,7 +16,7 @@ Description=Periodically run the Diet Guard gate check
# reboot), leaving the timer dormant (NextElapse=infinity). OnCalendar re-arms
# every period regardless of how long the gate stayed open, and Persistent
# catches a run missed while the machine was suspended.
OnCalendar=*-*-* *:00/30:00
OnCalendar=*-*-* *:00/15:00
Persistent=true
[Install]

View File

@ -116,10 +116,11 @@ def _print_entry_line(entry: dict[str, object]) -> None:
def _print_slot_status() -> None:
"""Print each meal slot as logged / DUE / upcoming for today."""
logged = logged_slots_today()
schedule = current_schedule()
logged = logged_slots_today(schedule)
due = set(due_slots())
parts: list[str] = []
for slot in day_slots(current_schedule()):
for slot in day_slots(schedule):
if slot in logged:
mark = "logged"
elif slot in due:

View File

@ -87,9 +87,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
)
ate.add_argument(
"--hour",
type=int,
type=str,
default=None,
help="Slot hour on --date this meal satisfies (e.g. 20); requires --date.",
help="Meal slot on --date this meal satisfies, as HH:MM or HH (e.g."
" 07:15 or 20); must be one of that day's slots. Requires --date.",
)
sub.add_parser("status", help="Show today's calories and budget band.")

View File

@ -19,7 +19,7 @@ from typing import TYPE_CHECKING
from diet_guard._kuchnia_spread import assign_slots
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slots import day_slots
from diet_guard._slots import day_slots, slot_label
from diet_guard._state import now_local
if TYPE_CHECKING: # pragma: no cover - typing only
@ -99,7 +99,7 @@ def cmd_kuchnia(
emit(f"{len(dishes)} dish(es) delivered today, banked:")
for item in slotted:
emit(
f" {item.slot:02d}:00 {item.dish.name} "
f" {slot_label(item.slot)} {item.dish.name} "
f"({item.dish.kcal:g} kcal, {item.dish.grams:g} g)",
)
total = sum(dish.kcal for dish in dishes)

View File

@ -22,7 +22,8 @@ from diet_guard._kuchnia_import import refresh_delivery_once
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import resolve_future_when, slot_for_log
from diet_guard._slot_wire import parse_hhmm
from diet_guard._slots import resolve_future_when, slot_for_log, slot_label
from diet_guard._state import log_meal, now_local
from diet_guard._sync_events import publish_after_log_detached
@ -66,15 +67,17 @@ class Portion:
for a per-100 g label), or None to treat the macros as totals.
date: Log against this future date (``YYYY-MM-DD``) instead of now.
Must be given together with ``hour``. None means "now".
hour: The slot hour on ``date`` this meal satisfies. Required
together with ``date``.
hour: The slot on ``date`` this meal satisfies, as the user typed it
(``HH:MM`` or ``HH``; parsed by
:func:`diet_guard._slot_wire.parse_hhmm`). Required together with
``date``.
"""
grams: float | None
count: float | None
per_grams: float | None
date: str | None = None
hour: int | None = None
hour: str | None = None
def eaten_grams(
@ -116,13 +119,15 @@ def _resolve_target(
limit.
Raises:
ValueError: ``portion.date``/``portion.hour`` are an invalid future
ValueError: ``portion.hour`` is not a clock time, or
``portion.date``/``portion.hour`` are an invalid future
combination -- see :func:`diet_guard._slots.resolve_future_when`.
"""
if portion.date is None or portion.hour is None:
return None, slot_for_log(now_local(), schedule)
when = resolve_future_when(portion.date, portion.hour, schedule, now_local())
return when, slot_for_log(when, schedule)
slot = parse_hhmm(portion.hour)
when = resolve_future_when(portion.date, slot, schedule, now_local())
return when, slot
def cmd_ate(
@ -195,7 +200,7 @@ def cmd_ate(
emit(
f"logged: {description} {nutrition.kcal:g} kcal "
f"({macro_str}) [{nutrition.source}, {portion_str}]"
f"{f' for {portion.date} {portion.hour:02d}:00' if when else ''}",
f"{f' for {portion.date} {slot_label(slot)}' if when else ''}",
)
# Publish straight away rather than waiting for a periodic tick: until this
# lands, the phone still believes this slot is unlogged and will nag for it.

View File

@ -76,18 +76,11 @@ OFF_USER_AGENT: str = "diet_guard/1.0 (personal diet tracker)"
DEFAULT_PORTION_GRAMS: float = 100.0
# --- Gate (log-to-unlock) ---------------------------------------------------
# The gate is driven by FIXED MEAL SLOTS, not by a gap timer. Starting at the
# day-start hour, a slot opens every interval; once a slot's hour has passed,
# that slot must carry a logged meal or the screen locks until it does. This
# makes tracking fully automatic (you are prompted on a schedule rather than
# trusted to log voluntarily) and nudges regular eating. Coming home late
# naturally produces several unlogged elapsed slots at once -> one lock that
# backfills the whole day, which is the "requirement to access the PC" behavior.
GATE_DAY_START_HOUR: int = 8 # first slot (08:00); also the "beginning of day"
GATE_SLOT_INTERVAL_HOURS: int = 4 # slots at 08:00, 12:00, 16:00, 20:00
# Past this hour the gate never fires, so an unlogged late slot lapses quietly
# instead of locking you out overnight. (A new day resets all slots at 00:00.)
GATE_EATING_END_HOUR: int = 22 # exclusive (22:00)
# The gate is driven by MEAL SLOTS, not by a gap timer: once a slot's minute has
# passed, that slot must carry a logged meal or the screen locks until it does.
# Coming home late naturally produces several unlogged elapsed slots at once ->
# one lock that backfills the whole day. The slot minutes and the overnight
# cutoff come from the user's schedule (``_meal_schedule``), not constants here.
# flock single-instance guard: stops a timer from stacking lock windows.
GATE_LOCK_FILE: Path = DATA_DIR / ".gate.lock"

View File

@ -39,7 +39,7 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]:
now: Reference time (defaults to the current local time); injectable.
Returns:
The slot hours that still need a meal logged (empty == nothing due).
The slot minutes that still need a meal logged (empty == nothing due).
Empty on a shared free day, whatever the schedule says. The pool is read
from a single local file and never the network, so this cannot hang the
@ -49,7 +49,11 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]:
reference = now if now is not None else now_local()
if freedays.is_free_day(reference.date()):
return ()
return missing_slots(reference, logged_slots_today(), current_schedule())
# One schedule for both halves: snapping the log onto a different schedule
# than the one deriving the due slots would leave a logged meal covering a
# checkpoint that is not on the list -- and the real one nagging.
schedule = current_schedule()
return missing_slots(reference, logged_slots_today(schedule), schedule)
def gate_is_due(now: datetime | None = None) -> bool:

View File

@ -97,7 +97,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]:
demo_mode: Whether the window is a safe sandbox.
Returns:
The slot hours to collect, ascending.
The slot minutes to collect, ascending.
"""
pending = list(due_slots())
if pending:

View File

@ -4,11 +4,27 @@ Split out of :mod:`._gatelock_calendar_widgets` (216 lines against the repo's
250-line cap) rather than added to it. The behaviour behind these widgets
lives in :mod:`._gatelock_scheduleedit`, mirroring how the budget row's
widgets and its edit/save logic are split.
The first and last meal are editable ``ttk.Spinbox`` fields: the user may type
any ``HH:MM``, and the arrow buttons, Up/Down and the mouse wheel step to the
neighbouring quarter hour (:func:`step_time`). The ``ttk`` import is
load-bearing for the tests, which patch this module's ``ttk`` with a fake --
keep binding it at module level.
**No popup of any kind.** These used to be ``ttk.Combobox`` fields, and on
Xvfb (2026-10-10, demo gate) a posted ``ComboboxPopdown`` mapped *beneath*
the lock surface, which gatelock keeps raised, while still holding ttk's grab:
invisible, it swallowed the next click and stole keystrokes. A spinbox posts
nothing. Every way ttk spins one -- arrow press, Up/Down, the wheel --
generates ``<<Increment>>``/``<<Decrement>>`` (``ttk/spinbox.tcl``), and those
are bound here and answered with ``"break"``, so ttk's own ``values``/``wrap``
logic never runs: stepping is always :func:`step_time` from the typed value.
"""
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING
from gatelock import make_button
@ -17,19 +33,98 @@ from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, LABEL
from diet_guard._gatelock_ui import BG, FG
from diet_guard._meal_schedule import MINUTES_PER_DAY, SLOT_GRID_MINUTES
from diet_guard._slot_wire import parse_hhmm
from diet_guard._slots import slot_label
if TYPE_CHECKING:
from collections.abc import Callable
from diet_guard._gatelock_calendar_types import CalendarVars
__all__ = ["build_schedule_row"]
__all__ = ["FIRST_RANGE", "LAST_RANGE", "build_schedule_row", "step_time"]
_ENTRY_WIDTH = 4
# "HH:MM" plus room for the caret, so a typed time is never clipped.
_TIME_WIDTH = 6
_LAST_MARK = MINUTES_PER_DAY - SLOT_GRID_MINUTES # 23:45, the day's last grid mark
#: Where stepping clamps each field, as ``(low, high)`` minutes. The last meal
#: must sit at least one grid step after the first, so it can never be 00:00.
FIRST_RANGE = (0, _LAST_MARK)
LAST_RANGE = (SLOT_GRID_MINUTES, _LAST_MARK)
def step_time(text: str, direction: int, bounds: tuple[int, int] = FIRST_RANGE) -> str:
"""Return ``text`` moved to the next quarter hour in ``direction``.
An off-grid time (07:23) steps to the neighbouring grid mark (07:30 up,
07:15 down) rather than by a flat 15, so stepping always lands on the
grid. Clamped to ``bounds``; unparsable text is returned unchanged so a
half-typed value is not clobbered.
Args:
text: The field's current value.
direction: ``+1`` for later, ``-1`` for earlier.
bounds: The ``(low, high)`` minutes the result is clamped to.
Returns:
The stepped ``HH:MM`` label, or ``text`` if it does not parse.
"""
try:
minute = parse_hhmm(text)
except ValueError:
return text
if direction > 0:
stepped = (minute // SLOT_GRID_MINUTES + 1) * SLOT_GRID_MINUTES
else:
stepped = (minute - 1) // SLOT_GRID_MINUTES * SLOT_GRID_MINUTES
low, high = bounds
return slot_label(max(low, min(high, stepped)))
def _bind_steps(
spin: ttk.Spinbox, variable: tk.StringVar, bounds: tuple[int, int]
) -> None:
"""Answer the spinbox's step events with :func:`step_time`."""
def _step(direction: int) -> str:
if str(spin.cget("state")) == "normal":
variable.set(step_time(variable.get(), direction, bounds))
# "break" keeps ttk's own Spin (values/from/to/wrap) from running.
return "break"
spin.bind("<<Increment>>", lambda _event: _step(1))
spin.bind("<<Decrement>>", lambda _event: _step(-1))
def _time_spin(
row: tk.Frame, variable: tk.StringVar, bounds: tuple[int, int]
) -> ttk.Spinbox:
"""Return one locked-by-default ``HH:MM`` spinbox in the schedule row.
Locked means ``disabled``, not ``readonly``: a read-only ttk spinbox still
spins, which would edit a "locked" row. Its colours come from the
``TSpinbox`` style (see
:func:`diet_guard._gatelock_calendar_ui._style_notebook`) -- ttk widgets
reject ``bg``/``fg``.
"""
spin = ttk.Spinbox(
row,
textvariable=variable,
font=(_COLORS.typography.font_family, BODY),
width=_TIME_WIDTH,
justify="center",
state="disabled",
)
_bind_steps(spin, variable, bounds)
spin.pack(side="left", padx=(XS, SM), ipady=XS)
return spin
def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry:
"""Return one read-only-by-default numeric entry in the schedule row."""
"""Return the read-only-by-default meal-count entry in the schedule row."""
entry = tk.Entry(
row,
textvariable=variable,
@ -46,45 +141,38 @@ def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry:
return entry
def _caption(row: tk.Frame, text: str) -> None:
"""Pack one static label between the row's fields."""
tk.Label(
row,
text=text,
font=(_COLORS.typography.font_family, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
def build_schedule_row(
parent: tk.Frame,
vars_: CalendarVars,
on_edit_or_save_schedule: Callable[[], None],
) -> tuple[tk.Entry, tk.Entry, tk.Entry, tk.Button, tk.Label]:
) -> tuple[ttk.Spinbox, ttk.Spinbox, tk.Entry, tk.Button, tk.Label]:
"""Build the meal-schedule row.
Returns the three hour/count entries, the edit button, and the status
label, in that order. Like the budget row the entries start read-only:
the schedule is displayed but not directly typeable until "Edit".
Returns the two time spinboxes, the count entry, the edit button, and the
status label, in that order. Like the budget row the fields start locked:
the schedule is displayed but not directly editable until "Edit".
Returns:
``(first_entry, last_entry, count_entry, edit_button, status_label)``.
``(first_spin, last_spin, count_entry, edit_button, status_label)``.
"""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(SM, XS))
tk.Label(
row,
text="Meals:",
font=(_COLORS.typography.font_family, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
first_entry = _spin_entry(row, vars_.schedule.first)
tk.Label(
row,
text="to",
font=(_COLORS.typography.font_family, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
last_entry = _spin_entry(row, vars_.schedule.last)
tk.Label(
row,
text="x",
font=(_COLORS.typography.font_family, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
_caption(row, "Meals:")
first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE)
_caption(row, "to")
last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE)
_caption(row, "x")
count_entry = _spin_entry(row, vars_.schedule.count)
edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule)
edit_button.pack(side="left")
@ -106,4 +194,4 @@ def build_schedule_row(
fg=FG,
)
status_label.pack(pady=(0, XS))
return first_entry, last_entry, count_entry, edit_button, status_label
return first_spin, last_spin, count_entry, edit_button, status_label

View File

@ -19,6 +19,7 @@ from gatelock import LockConfig
if TYPE_CHECKING:
from collections.abc import Callable
from tkinter import ttk
# BG/FG/ERR come from _gatelock_ui's public exports (themselves sourced from
# LockConfig -- see DESIGN_AUDIT_TODO.md). The rest of the calendar's palette
@ -60,7 +61,9 @@ class ScheduleVars:
status: tk.StringVar
first: tk.StringVar
"""The first meal as ``HH:MM`` text, parsed only on save."""
last: tk.StringVar
"""The last meal as ``HH:MM`` text, parsed only on save."""
count: tk.StringVar
times: tk.StringVar
"""The derived checkpoint times, e.g. ``"08:00 11:00 14:00"``."""
@ -89,8 +92,8 @@ class CalendarWidgets:
budget_entry: tk.Entry
budget_edit_button: tk.Button
budget_status_label: tk.Label
schedule_first_entry: tk.Entry
schedule_last_entry: tk.Entry
schedule_first_entry: ttk.Spinbox
schedule_last_entry: ttk.Spinbox
schedule_count_entry: tk.Entry
schedule_edit_button: tk.Button
schedule_status_label: tk.Label

View File

@ -52,7 +52,10 @@ __all__ = [
def _style_notebook(root: tk.Misc) -> None:
"""Theme the ``ttk.Notebook`` tab strip to match the gate's dark palette.
"""Theme the History tab's ttk widgets to match the gate's dark palette.
Covers the ``ttk.Notebook`` tab strip and the schedule row's
``ttk.Spinbox`` fields.
``ttk`` widgets ignore plain ``bg=``/``fg=`` -- unlike every other widget
here, they only take color from a named ``ttk.Style``. Without this the
@ -77,3 +80,21 @@ def _style_notebook(root: tk.Misc) -> None:
background=[("selected", _ACCENT)],
foreground=[("selected", _COLORS.palette.on_fill)],
)
# The meal-schedule row's HH:MM spinboxes. Locked they are ``disabled``,
# which clam greys out; mapping the disabled state back to the field
# colours keeps the stored schedule legible, matching the read-only
# ``tk.Entry`` beside them.
style.configure(
"TSpinbox",
fieldbackground=_FIELD_BG,
background=_FIELD_BG,
foreground=FG,
arrowcolor=FG,
insertcolor=FG,
)
style.map(
"TSpinbox",
fieldbackground=[("disabled", _FIELD_BG), ("readonly", _FIELD_BG)],
foreground=[("disabled", FG)],
arrowcolor=[("disabled", _COLORS.palette.muted)],
)

View File

@ -28,7 +28,7 @@ from diet_guard._gatelock_kuchnia import start_delivery_fetch
from diet_guard._kuchnia_log import fill_plan, log_dishes
from diet_guard._kuchnia_spread import assign_slots
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slots import day_slots
from diet_guard._slots import day_slots, slot_label
from diet_guard._state import now_local
if TYPE_CHECKING: # pragma: no cover - typing only
@ -144,7 +144,9 @@ class FillAllFlow:
self._plan = plan
self._label.set(f"✓ Confirm ({len(plan)})")
total = sum(item.dish.kcal for item in plan)
listing = ", ".join(f"{item.slot:02d}:00 {item.dish.name}" for item in plan)
listing = ", ".join(
f"{slot_label(item.slot)} {item.dish.name}" for item in plan
)
self._set_status(
f"Will log {len(plan)} ({total:g} kcal): {listing} — click Confirm."
)

View File

@ -11,6 +11,10 @@ before it is written rather than relying on
:meth:`~diet_guard._meal_schedule.MealSchedule.normalized` to clamp it. The
clamp is the defence against a peer's corrupt sync data; a typo at the
keyboard should say so instead of silently becoming a different schedule.
The first and last meal are ``HH:MM`` times (any minute, not just the
quarter hours the dropdown offers); the rules mirror ``normalized()`` exactly
so that a schedule accepted here is stored unchanged.
"""
from __future__ import annotations
@ -22,16 +26,20 @@ import tkinter as tk
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
from diet_guard._gatelock_ui import ERR, FG
from diet_guard._meal_schedule import (
FIRST_HOUR,
LAST_HOUR,
MAX_MEAL_COUNT,
MIN_MEAL_COUNT,
SLOT_GRID_MINUTES,
MealSchedule,
)
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
from diet_guard._slot_wire import parse_hhmm
from diet_guard._slots import day_slots, slot_label
__all__ = ["_GateScheduleEdit", "schedule_summary"]
__all__ = ["_GateScheduleEdit", "schedule_problem", "schedule_summary"]
# ttk.Spinbox and tk.Entry spell "locked" differently: a ``readonly`` ttk
# spinbox still spins, which would edit a locked row, so locked is ``disabled``.
_SPIN_STATE = {"normal": "normal", "readonly": "disabled"}
def schedule_summary(schedule: MealSchedule) -> str:
@ -39,6 +47,27 @@ def schedule_summary(schedule: MealSchedule) -> str:
return " ".join(slot_label(slot) for slot in day_slots(schedule))
def schedule_problem(schedule: MealSchedule) -> str | None:
"""Return why ``schedule`` cannot be saved as typed, or None if it can.
Each rule is one ``normalized()`` would otherwise apply silently: the
15-minute minimum window, the meal-count range, and the capacity of the
window -- one checkpoint per grid step, because two meals closer than that
would snap onto the same quarter hour and a duplicate slot silently drops
a checkpoint.
"""
first, last = schedule.first_minute, schedule.last_minute
if last < first + SLOT_GRID_MINUTES:
return (
f"The last meal must be at least {SLOT_GRID_MINUTES} min after the first."
)
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
return f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}."
if schedule.count > (last - first) // SLOT_GRID_MINUTES + 1:
return "Too many meals for that window -- widen it or eat less often."
return None
class _GateScheduleEdit(_GateBudgetEdit):
"""The History tab's meal-schedule row: display, edit, validate, persist.
@ -55,11 +84,16 @@ class _GateScheduleEdit(_GateBudgetEdit):
"""Repaint the History tab; implemented by ``_GateCalendar``."""
def _set_schedule_entry_state(self, state: str) -> None:
"""Lock or unlock the three schedule entries on every monitor."""
"""Lock (``"readonly"``) or unlock (``"normal"``) the row's fields.
One suppress per widget: a monitor that vanished mid-update must not
stop the remaining fields from changing state.
"""
for surface in self._cal_surfaces:
for spin in (surface.schedule_first_entry, surface.schedule_last_entry):
with contextlib.suppress(tk.TclError):
spin.configure(state=_SPIN_STATE[state])
with contextlib.suppress(tk.TclError):
surface.schedule_first_entry.config(state=state)
surface.schedule_last_entry.config(state=state)
surface.schedule_count_entry.config(state=state)
def _set_schedule_button_text(self, text: str) -> None:
@ -78,8 +112,8 @@ class _GateScheduleEdit(_GateBudgetEdit):
def _show_schedule(self, schedule: MealSchedule) -> None:
"""Populate the row's fields and derived-times label."""
self._cal_vars.schedule.first.set(str(schedule.first))
self._cal_vars.schedule.last.set(str(schedule.last))
self._cal_vars.schedule.first.set(slot_label(schedule.first_minute))
self._cal_vars.schedule.last.set(slot_label(schedule.last_minute))
self._cal_vars.schedule.count.set(str(schedule.count))
self._cal_vars.schedule.times.set(schedule_summary(schedule))
@ -104,16 +138,19 @@ class _GateScheduleEdit(_GateBudgetEdit):
self._refresh_calendar()
def _read_schedule_fields(self) -> MealSchedule | None:
"""Parse the three entries, or None (with a status set) if unusable."""
raw = (
self._cal_vars.schedule.first.get().strip(),
self._cal_vars.schedule.last.get().strip(),
self._cal_vars.schedule.count.get().strip(),
)
"""Parse the three fields, or None (with a status set) if unusable."""
try:
first, last, count = (int(value) for value in raw)
first = parse_hhmm(self._cal_vars.schedule.first.get())
last = parse_hhmm(self._cal_vars.schedule.last.get())
except ValueError:
self._set_schedule_status("Enter whole hours, e.g. 8 20 5.", error=True)
self._set_schedule_status(
"Enter meal times as HH:MM, e.g. 07:15.", error=True
)
return None
try:
count = int(self._cal_vars.schedule.count.get().strip())
except ValueError:
self._set_schedule_status("Enter meals per day as a number.", error=True)
return None
return MealSchedule(first, last, count)
@ -126,25 +163,9 @@ class _GateScheduleEdit(_GateBudgetEdit):
schedule = self._read_schedule_fields()
if schedule is None:
return False
if not FIRST_HOUR <= schedule.first < schedule.last <= LAST_HOUR:
self._set_schedule_status(
f"First meal must be before the last, both {FIRST_HOUR}-{LAST_HOUR}.",
error=True,
)
return False
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
self._set_schedule_status(
f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}.",
error=True,
)
return False
# A window of N hours holds at most N+1 whole-hour checkpoints; asking
# for more would round two meals onto the same hour.
if schedule.count > schedule.last - schedule.first + 1:
self._set_schedule_status(
"Too many meals for that window -- widen it or eat less often.",
error=True,
)
problem = schedule_problem(schedule)
if problem is not None:
self._set_schedule_status(problem, error=True)
return False
record_schedule_change(schedule)
self._show_schedule(current_schedule())

View File

@ -18,6 +18,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._estimator import Nutrition
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slot_wire import entry_slot_minute, satisfied_by_entries, slot_fields
from diet_guard._state import log_meal
from diet_guard._state_today import today_entries
@ -33,11 +35,18 @@ SOURCE = "kuchnia wikinga"
def _already_logged(entries: Sequence[dict[str, object]], name: str, slot: int) -> bool:
"""Return True when today's log already holds this dish in this slot."""
"""Return True when today's log already holds this dish at this slot minute.
Compares the entry's *recorded* minute (:func:`entry_slot_minute`), not its
snapped slot -- the app's catering fill uses the same rule, which is the
point: both devices must agree on which dishes are duplicates, or one
re-logs what the other skipped. Snapping still guards the fill flow,
because :func:`fill_plan` skips any slot an entry snaps to (occupancy).
"""
wanted = name.strip().casefold()
return any(
str(entry.get("desc", "")).strip().casefold() == wanted
and entry.get("slot") == slot
and entry_slot_minute(entry) == slot
for entry in entries
)
@ -56,7 +65,7 @@ def fill_plan(slotted: Sequence[SlottedDish]) -> list[SlottedDish]:
Returns:
The dishes to log, in the order given.
"""
taken = {entry.get("slot") for entry in today_entries()}
taken = satisfied_by_entries(today_entries(), current_schedule())
return [item for item in slotted if item.slot not in taken]
@ -95,6 +104,6 @@ def log_dishes(chosen: Sequence[SlottedDish]) -> list[str]:
log_meal(dish.name, dish_nutrition(dish), item.slot)
# Reflect the write locally so two identical dishes in one batch do not
# both land: today_entries() was read once, before the loop.
entries = [*entries, {"desc": dish.name, "slot": item.slot}]
entries = [*entries, {"desc": dish.name, **slot_fields(item.slot)}]
written.append(dish.name)
return written

View File

@ -12,7 +12,7 @@ non-decreasing, keeps the first dish on the first slot and the last on the
last, and doubles up the earliest slots rather than dropping anything.
Integer ``//`` only, never ``round()``. That is repo convention for slot
arithmetic (``docs/meal-schedule.md``): Python's banker's rounding and Dart's
arithmetic (``docs/DOCS-meal-schedule.md``): Python's banker's rounding and Dart's
half-away-from-zero disagree on ``.5``, and a slot one device offers while the
other does not is a checkpoint that can never be satisfied.
@ -44,18 +44,19 @@ if TYPE_CHECKING:
class SlottedDish(NamedTuple):
"""A dish paired with the meal-slot hour it was assigned to."""
"""A dish paired with the meal-slot minute it was assigned to."""
dish: Dish
slot: int
def assign_slots(dishes: Sequence[Dish], slots: Sequence[int]) -> list[SlottedDish]:
"""Pair each dish with a slot hour, following the provider's meal order.
"""Pair each dish with a slot minute, following the provider's meal order.
Args:
dishes: The day's dishes, in any order (sorted here by ``priority``).
slots: The configured slot hours, ascending, e.g. ``(8, 12, 16, 20)``.
slots: The configured slot minutes, ascending, e.g.
``(480, 720, 960, 1200)``.
Returns:
One :class:`SlottedDish` per dish, ordered by the provider's priority.

View File

@ -44,15 +44,19 @@ from pydantic import BaseModel
# the names are genuinely used -- drop this and the server starts write-only.
from diet_guard._mcp_read import get_averages, get_slots, get_status, list_today
from diet_guard._mcp_server import APPENDS, logger, mcp
from diet_guard._meal_schedule import is_wire_int
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import slot_for_log
from diet_guard._slot_wire import parse_hhmm
from diet_guard._slots import day_slots, slot_for_log, slot_label
from diet_guard._state import (
log_meal as record_meal,
)
from diet_guard._state import now_local
from diet_guard._sync_events import publish_after_log
_HOURS_PER_DAY = 24
# ──────────────────────────────────────────────────────────────
# Gated write tool (preview unless confirm=True; NEVER allowlist this)
# ──────────────────────────────────────────────────────────────
@ -74,12 +78,43 @@ class Macros(BaseModel):
fat: float = 0.0
def _resolve_slot_arg(slot: int | str | None) -> int:
"""Return the slot minute ``log_meal``'s ``slot`` argument names.
An int is the legacy whole-hour form (``12`` -> 720), kept so an existing
caller's prompt does not silently start logging at 00:12; a string is
``"HH:MM"``. The result must be one of today's slots: an entry tagged
with a minute the schedule never offers would still snap to *some* slot,
but which one would be a surprise to the caller.
Raises:
ValueError: ``slot`` is not a clock time, or not one of today's slots.
"""
schedule = current_schedule()
if slot is None:
return slot_for_log(now_local(), schedule)
if isinstance(slot, str):
minute = parse_hhmm(slot)
elif is_wire_int(slot) and 0 <= slot < _HOURS_PER_DAY:
# ``is_wire_int`` rejects ``True``: as an int it is 1, i.e. 01:00.
minute = slot * 60
else:
msg = f"slot {slot} is not an hour 0-23; pass a time like '07:15'"
raise ValueError(msg)
slots = day_slots(schedule)
if minute not in slots:
labels = ", ".join(slot_label(each) for each in slots)
msg = f"{slot_label(minute)} is not a meal slot today; choose one of {labels}"
raise ValueError(msg)
return minute
@mcp.tool(title="Log a meal (gated write)", annotations=APPENDS)
def log_meal(
description: str,
grams: float | None = None,
macros: Macros | None = None,
slot: int | None = None,
slot: int | str | None = None,
*,
confirm: bool = False,
) -> dict[str, Any]:
@ -104,15 +139,22 @@ def log_meal(
macros: Manually-entered nutrition (``kcal`` plus optional
protein/carbs/fat). When given, lookups are skipped and the meal is
logged exactly as specified.
slot: The meal-slot hour to satisfy; when omitted (or None) it falls
back to ``slot_for_log``, which clamps to a real hour rather than
returning None, so the entry always satisfies some slot.
slot: The meal slot to satisfy, as ``"HH:MM"`` (e.g. ``"07:15"``) or
a legacy int hour (e.g. ``12`` for 12:00); it must be one of
today's slots (see ``get_slots``). When omitted (or None) it
falls back to ``slot_for_log``, which clamps to a real slot rather
than returning None, so the entry always satisfies some slot.
``target_slot`` in the result is the slot's minute of day.
confirm: Set ``True`` to actually append the entry; otherwise preview.
Returns:
A preview or applied result dict; ``{"ok": false, "reason": ...}`` when
the food cannot be resolved or the log cannot be written.
"""
try:
target_slot = _resolve_slot_arg(slot)
except ValueError as exc:
return {"ok": False, "reason": str(exc)}
manual_macros = (
ManualMacros(
kcal=macros.kcal,
@ -132,9 +174,6 @@ def log_meal(
"or Open Food Facts. Pass kcal=<number> to log it manually."
),
}
target_slot = (
slot if slot is not None else slot_for_log(now_local(), current_schedule())
)
resolved = {
"kcal": nutrition.kcal,
"protein_g": nutrition.protein_g,

View File

@ -21,6 +21,7 @@ from diet_guard._budget import BudgetError, current_schedule, daily_budget
from diet_guard._gate import due_slots
from diet_guard._mcp_server import READS_ONLY, mcp
from diet_guard._meal_schedule_store import current_schedule as current_meal_schedule
from diet_guard._slot_wire import entry_slot_minute
from diet_guard._slots import current_slot, day_slots, slot_label
from diet_guard._state import (
load_log,
@ -62,7 +63,9 @@ def _entry_view(entry: dict[str, object]) -> dict[str, Any]:
"fat_g": entry.get("fat_g"),
"grams": entry.get("grams"),
"source": entry.get("source"),
"slot": entry.get("slot"),
# The recorded slot minute (``slot_min``, else the legacy hour x 60),
# in the same unit as every other slot this server reports.
"slot": entry_slot_minute(entry),
}
@ -78,8 +81,9 @@ def get_status() -> dict[str, Any]:
Reports the calories and macros *consumed* so far, the qualitative
:func:`consumption_band` (``"on track"`` / ``"approaching limit"`` /
``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the
meal-slot picture (which slots are due, which are already logged, and the
current slot). The raw budget number is intentionally withheld -- only the
meal-slot picture (which slots are due, as ``HH:MM`` labels; which are
already logged and the current slot, as minutes of day -- e.g. 435 for
07:15). The raw budget number is intentionally withheld -- only the
band is exposed, mirroring how the CLI status shows a label to an automated
caller rather than the anchor number.
"""
@ -88,14 +92,15 @@ def get_status() -> dict[str, Any]:
except BudgetError:
# No budget set (or a corrupt file): surface the absence, not a number.
band = None
schedule = current_meal_schedule()
return {
"consumed_kcal": today_total_kcal(),
"consumed_macros_g": _macros_dict(today_total_macros()),
"consumption_band": band,
"budget_initialized": band is not None,
"due_slots": [slot_label(slot) for slot in due_slots()],
"logged_slots": sorted(logged_slots_today()),
"current_slot": current_slot(now_local(), current_meal_schedule()),
"logged_slots": sorted(logged_slots_today(schedule)),
"current_slot": current_slot(now_local(), schedule),
}
@ -104,8 +109,8 @@ def list_today() -> dict[str, Any]:
"""List today's logged meals (valid entries only), newest last.
Returns the per-entry description, calories, macros, portion, source, and
slot -- the same data the CLI ``status`` listing renders, minus the internal
HMAC signature.
recorded slot minute -- the same data the CLI ``status`` listing renders,
minus the internal HMAC signature.
"""
entries = today_entries()
return {
@ -172,15 +177,18 @@ def get_averages() -> dict[str, Any]:
@mcp.tool(title="Meal slots for the day", annotations=READS_ONLY)
def get_slots() -> dict[str, Any]:
"""Return the day's fixed meal slots and which one is current.
"""Return today's meal slots and which one is current.
Pure schedule information (08:00 / 12:00 / 16:00 / 20:00 by default) with no
budget or intake data attached.
budget or intake data attached. Each slot is ``{"minute": m, "label":
"HH:MM"}`` with ``m`` the minute of day (e.g. 435 for 07:15); the label is
what ``log_meal``'s ``slot`` argument accepts. ``current_slot`` is a
minute too, or None before the first slot.
"""
schedule = current_meal_schedule()
return {
"day_slots": [
{"hour": slot, "label": slot_label(slot)} for slot in day_slots(schedule)
{"minute": slot, "label": slot_label(slot)} for slot in day_slots(schedule)
],
"current_slot": current_slot(now_local(), schedule),
}

View File

@ -34,7 +34,12 @@ import json
import logging
from diet_guard._constants import MEAL_SCHEDULE_FILE
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._meal_schedule import (
DEFAULT_SCHEDULE,
MealSchedule,
schedule_from_wire,
schedule_to_wire,
)
_logger = logging.getLogger(__name__)
@ -65,33 +70,27 @@ class ScheduleEntry:
def entry_to_json(entry: ScheduleEntry) -> dict[str, object]:
"""Return the wire/disk form of one entry's value."""
return {
"f": entry.schedule.first,
"l": entry.schedule.last,
"n": entry.schedule.count,
"t": entry.edited_at,
}
# ``schedule_to_wire`` keeps a whole-hour schedule byte-identical to the
# pre-minute ``{"f", "l", "n"}`` form, so an older build reading this file
# still sees the hours it understands.
return {**schedule_to_wire(entry.schedule), "t": entry.edited_at}
def entry_from_json(effective_from: str, raw: object) -> ScheduleEntry | None:
"""Return an entry parsed from its stored value, or None if unusable.
Never raises: a malformed entry is skipped so one bad field from a peer
cannot take out the whole history.
cannot take out the whole history. ``schedule_from_wire`` normalises on
the way in, so a peer running a future version with a wider range cannot
hand us a schedule we would derive differently.
"""
if not isinstance(raw, dict):
return None
first, last, count = raw.get("f"), raw.get("l"), raw.get("n")
if not (
isinstance(first, int) and isinstance(last, int) and isinstance(count, int)
):
schedule = schedule_from_wire(raw)
if schedule is None or not isinstance(raw, dict):
return None
edited_at = raw.get("t")
return ScheduleEntry(
effective_from=effective_from,
# Normalising on the way in means a peer running a future version with
# a wider range cannot hand us a schedule we would derive differently.
schedule=MealSchedule(first, last, count).normalized(),
schedule=schedule,
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
)

View File

@ -26,9 +26,12 @@ import re
from typing import TYPE_CHECKING
from diet_guard._meal_schedule import MINUTES_PER_DAY, is_wire_int
from diet_guard._slots import satisfied_slots
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from diet_guard._meal_schedule import MealSchedule
# ASCII digits only: ``\d`` would also accept Arabic-Indic and other Unicode
# digits, which ``int()`` happily parses.
@ -81,6 +84,30 @@ def entry_slot_minute(entry: Mapping[str, object]) -> int | None:
return None
def satisfied_by_entries(
entries: Iterable[Mapping[str, object]], schedule: MealSchedule
) -> set[int]:
"""Return the slots of ``schedule`` that ``entries`` satisfy.
The one composition every "is this slot logged?" site goes through: read
each entry's recorded minute, then snap it to its nearest slot. Routing
every site through here is what keeps the gate, the CLI, MCP and the
catering import from disagreeing about one entry after a schedule edit --
an exact-match site would re-nag for a meal another site counts.
Args:
entries: Decoded log entries; ones recording no slot are ignored.
schedule: The schedule in force on the entries' day.
Returns:
The satisfied slot minutes (possibly empty).
"""
minutes = (entry_slot_minute(entry) for entry in entries)
return satisfied_slots(
(minute for minute in minutes if minute is not None), schedule
)
def parse_hhmm(text: str) -> int:
"""Parse a user-typed clock time into a minute of day.

View File

@ -24,6 +24,7 @@ from gatelock.log_integrity import (
from diet_guard._coerce import as_float
from diet_guard._constants import FOOD_LOG_FILE
from diet_guard._slot_wire import slot_fields
if TYPE_CHECKING:
from diet_guard._estimator import Nutrition
@ -137,9 +138,12 @@ def log_meal(
Args:
description: The user's free-text meal description.
nutrition: Estimated nutrition for the portion eaten.
slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the
12:00 checkpoint). When None the entry still counts toward the
day's calories but does not mark any slot as logged.
slot: The meal-slot *minute of day* this entry satisfies (e.g.
``720`` for the 12:00 checkpoint, ``435`` for 07:15). Encoded
through :func:`diet_guard._slot_wire.slot_fields`, so a whole-hour
slot is written exactly as before the move to minutes. When None
the entry still counts toward the day's calories but does not mark
any slot as logged.
components: For a composite (multi-item) meal, each component's own
name and macros. Carried on the log entry itself -- not just the
food bank -- so a bank rebuilt purely by replaying the log (the
@ -173,7 +177,9 @@ def log_meal(
"source": nutrition.source,
}
if slot is not None:
entry["slot"] = slot
# Before signing: the HMAC covers the whole dict, so a ``slot_min``
# added afterwards would make the entry fail verification and vanish.
entry.update(slot_fields(slot))
if components is not None:
entry["components"] = list(components)
signature = compute_entry_hmac(entry)

View File

@ -18,8 +18,12 @@ already import them from.
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._budget import daily_budget
from diet_guard._constants import BUDGET_WARN_FRACTION
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slot_wire import satisfied_by_entries
from diet_guard._state import (
_entry_float,
_today,
@ -27,6 +31,9 @@ from diet_guard._state import (
load_log,
)
if TYPE_CHECKING:
from diet_guard._meal_schedule import MealSchedule
__all__ = [
"consumption_band",
"logged_slots_today",
@ -65,8 +72,14 @@ def today_total_macros() -> tuple[float, float, float]:
return round(protein, 1), round(carbs, 1), round(fat, 1)
def logged_slots_today() -> set[int]:
"""Return the set of meal-slot hours already covered by today's log.
def logged_slots_today(schedule: MealSchedule | None = None) -> set[int]:
"""Return the set of today's slot minutes already covered by today's log.
Each entry satisfies the slot *nearest* its recorded minute
(:func:`diet_guard._slot_wire.satisfied_by_entries`), so a meal logged at
07:00 still covers a checkpoint the user has since moved to 07:15.
Without the snap, every schedule edit would re-open the slots already
eaten that day and lock the user out for meals they did log.
Only valid (HMAC-verified) entries count, so stripping entries to dodge a
checkpoint makes that slot reappear as unsatisfied -- the fail-closed
@ -74,23 +87,22 @@ def logged_slots_today() -> set[int]:
checkpoint) contributes calories but satisfies no slot.
Nothing produces a slot-less entry any more: the phone's "Snack" chip was
removed on 2026-08-14, and every remaining writer resolves a concrete hour
removed on 2026-08-14, and every remaining writer resolves a concrete slot
through ``slot_for_log`` -- the CLI (:mod:`_cli`), the gate
(:mod:`_gatelock_mealflow`) and the MCP tool (:mod:`_mcp`, which falls back
to it whenever its ``slot`` argument is omitted or None). The check below
is still load-bearing for entries already on disk and arriving over sync --
removing it would make every historical snack retroactively satisfy a meal
slot.
to it whenever its ``slot`` argument is omitted or None). Skipping
slot-less entries (in ``satisfied_by_entries``) is still load-bearing for
entries already on disk and arriving over sync -- removing it would make
every historical snack retroactively satisfy a meal slot.
Args:
schedule: The schedule to snap onto; defaults to today's.
Returns:
The distinct integer slot hours logged today (possibly empty).
The distinct slot minutes satisfied today (possibly empty).
"""
slots: set[int] = set()
for entry in today_entries():
value = entry.get("slot")
if isinstance(value, int) and not isinstance(value, bool):
slots.add(value)
return slots
resolved = schedule if schedule is not None else current_schedule()
return satisfied_by_entries(today_entries(), resolved)
def remaining_budget() -> float:

View File

@ -11,7 +11,9 @@ predates meal schedules neither clobbers those fields nor blocks them -- it
relays them untouched. That is what makes this shippable without a
coordinated release.
The value is a small map (``{"f": 8, "l": 20, "n": 5}``) rather than a scalar.
The value is a small map (``{"f": 8, "l": 20, "n": 5}``, plus ``fm``/``lm``
minutes only for an off-hour endpoint -- see
:func:`diet_guard._meal_schedule.schedule_to_wire`) rather than a scalar.
``Record.to_dict``/``from_dict`` round-trip nested values unchanged, and a
malformed one is skipped exactly as a non-int ``hist:`` value is.
"""
@ -24,7 +26,7 @@ from typing import TYPE_CHECKING
from crdt_sync import Hlc
from diet_guard._device import device_id
from diet_guard._meal_schedule import MealSchedule
from diet_guard._meal_schedule import schedule_from_wire, schedule_to_wire
from diet_guard._meal_schedule_store import ScheduleEntry
from diet_guard.sync_merge._clock import _EPOCH
@ -64,11 +66,7 @@ def schedule_fields(
"""
return {
f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": (
{
"f": entry.schedule.first,
"l": entry.schedule.last,
"n": entry.schedule.count,
},
schedule_to_wire(entry.schedule),
schedule_hlc(entry),
)
for entry in entries
@ -90,21 +88,17 @@ def log_to_schedule_history(log: Log) -> tuple[ScheduleEntry, ...]:
for name, (value, hlc) in record.fields.items():
if not name.startswith(SCHEDULE_FIELD_PREFIX):
continue
if not isinstance(value, dict):
continue
first, last, count = value.get("f"), value.get("l"), value.get("n")
if not (
isinstance(first, int) and isinstance(last, int) and isinstance(count, int)
) or any(isinstance(part, bool) for part in (first, last, count)):
# Normalised on the way in, so a peer running a future version with a
# wider range cannot hand us a schedule we would derive slots
# differently from.
schedule = schedule_from_wire(value)
if schedule is None:
continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
entries.append(
ScheduleEntry(
effective_from=name[len(SCHEDULE_FIELD_PREFIX) :],
# Normalised on the way in, so a peer running a future version
# with a wider range cannot hand us a schedule we would derive
# slots differently from.
schedule=MealSchedule(first, last, count).normalized(),
schedule=schedule,
edited_at=edited.astimezone().isoformat(timespec="seconds"),
),
)

View File

@ -199,6 +199,9 @@ def fake_tk() -> Iterator[None]:
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK))
# The schedule row's HH:MM spinboxes are ttk widgets; left real, they
# would be built against a fake parent.
stack.enter_context(patch.object(_gatelock_calendar_schedule, "ttk", _FAKE_TTK))
yield

View File

@ -27,6 +27,7 @@ from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar
from diet_guard.tests._tk_fakes_containers import (
FakeNotebook,
FakeRadiobutton,
FakeSpinbox,
FakeStyle,
FakeWidget,
)
@ -45,6 +46,7 @@ __all__ = [
"FakeNotebook",
"FakeRadiobutton",
"FakeScrollbar",
"FakeSpinbox",
"FakeStyle",
"FakeText",
"FakeVar",
@ -174,7 +176,7 @@ _FAKE_TK = SimpleNamespace(
Event=object,
)
_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook, Style=FakeStyle)
_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook, Style=FakeStyle, Spinbox=FakeSpinbox)
# Every mixin module the gate window is built from imports ``tkinter``
# independently; all of them must see the fake so ``tk.TclError`` etc. are the

View File

@ -23,6 +23,7 @@ from __future__ import annotations
__all__ = [
"FakeNotebook",
"FakeRadiobutton",
"FakeSpinbox",
"FakeStyle",
"FakeWidget",
]
@ -181,3 +182,28 @@ class FakeStyle:
def map(self, style_name: str, **kwargs: object) -> None:
self.configured.setdefault(style_name, {}).update(kwargs)
class FakeSpinbox(FakeWidget):
"""A functional stand-in for ``ttk.Spinbox``.
Reads and writes go through its ``textvariable`` (as the real widget's
do), ``state`` is readable via ``cget``, and ``bind`` keeps each handler
so a test can fire ``<<Increment>>``/``<<Decrement>>`` the way an arrow
press, Up/Down or the wheel would.
"""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.bindings: dict[str, object] = {}
def cget(self, key: str) -> object:
return self.configured.get(key, "")
def get(self) -> str:
variable = self.configured.get("textvariable")
return str(variable.get()) if hasattr(variable, "get") else ""
def bind(self, *args: object, **kwargs: object) -> None:
sequence, handler = args[0], args[1]
self.bindings[str(sequence)] = handler

View File

@ -195,8 +195,8 @@ class TestStatus:
def test_slot_status_all_marks(self, capsys: pytest.CaptureFixture[str]) -> None:
"""The slot line shows logged / DUE / upcoming together."""
with (
patch.object(_cli, "logged_slots_today", return_value={8}),
patch.object(_cli, "due_slots", return_value=[12]),
patch.object(_cli, "logged_slots_today", return_value={480}),
patch.object(_cli, "due_slots", return_value=[720]),
):
_cli._print_slot_status()
out = capsys.readouterr().out

View File

@ -13,6 +13,8 @@ from diet_guard import _cli_log
from diet_guard._budget import write_budget
from diet_guard._cli import main
from diet_guard._estimator import Nutrition
from diet_guard._meal_schedule import MealSchedule
from diet_guard._meal_schedule_store import record_schedule_change
from diet_guard._state import load_log, now_local
if TYPE_CHECKING:
@ -57,3 +59,32 @@ class TestAteFutureDate:
yesterday = (now_local() - timedelta(days=1)).date().isoformat()
assert main(["ate", "x", "--date", yesterday, "--hour", "12"]) == 1
assert "not a future date" in capsys.readouterr().out
def test_an_hhmm_hour_pre_logs_an_off_hour_slot(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""--hour 07:15 names a minute slot and is echoed as HH:MM."""
record_schedule_change(MealSchedule(435, 1140, 5))
tomorrow = (now_local() + timedelta(days=1)).date().isoformat()
with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT):
assert main(["ate", "oats", "--date", tomorrow, "--hour", "07:15"]) == 0
assert f"for {tomorrow} 07:15" in capsys.readouterr().out
(entry,) = load_log()[tomorrow]
assert (entry["slot"], entry["slot_min"]) == (7, 435)
assert entry["time"].startswith(f"{tomorrow}T07:15")
def test_a_whole_hour_is_echoed_as_hhmm(
self, capsys: pytest.CaptureFixture[str]
) -> None:
tomorrow = (now_local() + timedelta(days=1)).date().isoformat()
with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT):
assert main(["ate", "x", "--date", tomorrow, "--hour", "20:00"]) == 0
assert f"for {tomorrow} 20:00" in capsys.readouterr().out
assert "slot_min" not in load_log()[tomorrow][0]
def test_an_unparseable_hour_errors(
self, capsys: pytest.CaptureFixture[str]
) -> None:
tomorrow = (now_local() + timedelta(days=1)).date().isoformat()
assert main(["ate", "x", "--date", tomorrow, "--hour", "noon"]) == 1
assert "expected HH:MM" in capsys.readouterr().out

View File

@ -216,5 +216,5 @@ class TestGateReadsFreshState:
"""At 09:00 only the 08:00 slot is due; logging it flips the decision."""
now = datetime(2026, 1, 1, 9, 0, tzinfo=UTC)
assert gate_is_due(now) is True
log_meal("oatmeal", self._nutrition(), slot=8)
log_meal("oatmeal", self._nutrition(), slot=480)
assert gate_is_due(now) is False

View File

@ -36,8 +36,8 @@ class TestDueSlots:
def test_injected_now(self) -> None:
"""With 08:00 logged at 13:00, only 12:00 is due."""
with _logged({8}):
assert due_slots(_at(13)) == (12,)
with _logged({480}):
assert due_slots(_at(13)) == (720,)
def test_default_now_uses_clock(self) -> None:
"""Omitting ``now`` reads the real clock (mocked here for determinism)."""
@ -48,7 +48,7 @@ class TestDueSlots:
return_value=_at(9),
),
):
assert due_slots() == (8,)
assert due_slots() == (480,)
class TestGateIsDue:
@ -61,7 +61,7 @@ class TestGateIsDue:
def test_not_due_when_all_logged(self) -> None:
"""Everything elapsed is logged -> no lock."""
with _logged({8, 12}):
with _logged({480, 720}):
assert gate_is_due(_at(13)) is False
@ -70,12 +70,12 @@ class TestGateMessage:
def test_all_logged(self) -> None:
"""Nothing missing -> the up-to-date message."""
with _logged({8, 12}):
with _logged({480, 720}):
assert "up to date" in gate_message(_at(13))
def test_single_missing(self) -> None:
"""One missing slot -> singular phrasing."""
with _logged({8}):
with _logged({480}):
assert gate_message(_at(13)) == "Log your 12:00 meal to unlock."
def test_multiple_missing(self) -> None:

View File

@ -0,0 +1,65 @@
"""A schedule edit must not re-open meals already logged that day.
End-to-end through the real public path -- ``log_meal`` writes signed entries
to the (redirected) log, ``record_schedule_change`` stores the schedule, and
``gate_is_due`` / ``logged_slots_today`` read both back -- with nothing mocked
in between. This is the user-approved satisfaction rule: each logged meal
satisfies the slot *nearest* its recorded minute, and nothing on disk is ever
rewritten. An exact-match rule would lock the user out for two meals they did
log, the moment they nudged breakfast by fifteen minutes.
"""
from __future__ import annotations
from datetime import UTC, datetime
from diet_guard._estimator import Nutrition
from diet_guard._gate import due_slots, gate_is_due
from diet_guard._meal_schedule import MealSchedule
from diet_guard._meal_schedule_store import record_schedule_change
from diet_guard._state import log_meal
from diet_guard._state_today import logged_slots_today
def _today_at(hour: int, minute: int = 0) -> datetime:
"""Return today's local date at ``hour:minute`` (the log is keyed by today)."""
now = datetime.now(tz=UTC).astimezone()
return now.replace(hour=hour, minute=minute, second=0, microsecond=0)
def _log_breakfast_and_snack_then_move_the_schedule() -> None:
"""07:00 and 10:00 logged under 07:00-19:00 x5, then moved to 07:15."""
record_schedule_change(MealSchedule(420, 1140, 5))
meal = Nutrition(300, 10, 40, 8, 200, "manual")
log_meal("oats", meal, slot=420)
log_meal("apple", meal, slot=600)
record_schedule_change(MealSchedule(435, 1140, 5))
class TestScheduleMoveKeepsLoggedMeals:
"""07:00/10:00 entries keep covering 07:15/10:15 after the edit."""
def test_the_moved_slots_are_derived_as_expected(self) -> None:
"""Precondition: the new schedule really moved both checkpoints."""
assert MealSchedule(435, 1140, 5).slots() == (435, 615, 795, 960, 1140)
def test_old_entries_satisfy_the_nearest_new_slots(self) -> None:
"""Nearest-slot snapping: 420 -> 435 and 600 -> 615."""
_log_breakfast_and_snack_then_move_the_schedule()
assert logged_slots_today() == {435, 615}
def test_nothing_is_due_at_eleven(self) -> None:
"""Both elapsed checkpoints are covered, so the gate stays down."""
_log_breakfast_and_snack_then_move_the_schedule()
assert not gate_is_due(_today_at(11))
def test_the_next_unlogged_slot_still_falls_due(self) -> None:
"""Snapping is not a free pass: 13:15 is due once it arrives."""
_log_breakfast_and_snack_then_move_the_schedule()
assert due_slots(_today_at(13, 15)) == (795,)
def test_the_moved_first_slot_opens_on_its_minute(self) -> None:
"""With nothing logged, 07:15 is due at 07:15 and not at 07:14."""
record_schedule_change(MealSchedule(435, 1140, 5))
assert not gate_is_due(_today_at(7, 14))
assert due_slots(_today_at(7, 15)) == (435,)

View File

@ -22,6 +22,7 @@ from unittest.mock import MagicMock
from diet_guard import (
_gatelock_calendar,
_gatelock_calendar_schedule,
_gatelock_calendar_ui,
_gatelock_ui,
_gatelock_widgetgroups,
@ -149,6 +150,7 @@ def test_fake_tk_actually_replaces_real_tkinter() -> None:
assert not wrong, f"fake_tk() left real tkinter in place for: {wrong}"
assert _gatelock_calendar.ttk is _FAKE_TTK
assert _gatelock_calendar_ui.ttk is _FAKE_TTK
assert _gatelock_calendar_schedule.ttk is _FAKE_TTK
# ...and is restored afterwards, so the fake cannot leak into other tests.
assert _gatelock_ui.tk is tk

View File

@ -69,8 +69,8 @@ class TestModuleHelpers:
def test_pending_slots_due(self) -> None:
"""When slots are due, those are returned verbatim."""
with patch.object(_gatelock, "due_slots", return_value=[12, 16]):
assert _pending_slots(demo_mode=False) == [12, 16]
with patch.object(_gatelock, "due_slots", return_value=[720, 960]):
assert _pending_slots(demo_mode=False) == [720, 960]
def test_pending_slots_demo_fallback(self) -> None:
"""Demo mode invents a representative slot when nothing is due."""

View File

@ -0,0 +1,99 @@
"""Tests for the schedule row's ``HH:MM`` spinboxes.
Covers :mod:`._gatelock_calendar_schedule`: the quarter-hour step and its
clamps, the ``<<Increment>>``/``<<Decrement>>`` bindings that replace ttk's own
spin logic, and the per-widget state toggle in :mod:`._gatelock_scheduleedit`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from unittest.mock import patch
import pytest
from diet_guard._gatelock_calendar_schedule import LAST_RANGE, step_time
from diet_guard.tests._tk_fakes import _FakeTclError
if TYPE_CHECKING:
from collections.abc import Callable
from diet_guard._gatelock import MealGate
from diet_guard.tests._tk_fakes import FakeSpinbox
class TestStepTime:
@pytest.mark.parametrize(
("text", "direction", "expected"),
[
("07:15", 1, "07:30"),
("07:15", -1, "07:00"),
# Off-grid steps to the neighbouring mark, not by a flat 15.
("07:23", 1, "07:30"),
("07:23", -1, "07:15"),
("7", 1, "07:15"),
# Clamped to the first meal's range: 00:00..23:45.
("00:00", -1, "00:00"),
("23:45", 1, "23:45"),
("23:50", 1, "23:45"),
],
)
def test_steps_onto_the_grid(
self, text: str, direction: int, expected: str
) -> None:
assert step_time(text, direction) == expected
def test_the_last_meal_never_steps_below_00_15(self) -> None:
"""It must sit one grid step after the earliest possible first meal."""
assert step_time("00:15", -1, LAST_RANGE) == "00:15"
assert step_time("00:20", -1, LAST_RANGE) == "00:15"
assert step_time("23:45", 1, LAST_RANGE) == "23:45"
def test_half_typed_text_is_left_alone(self) -> None:
assert step_time("07:", 1) == "07:"
def _spin(gate: MealGate, which: str, event: str) -> object:
"""Fire one of a spinbox's step events, as ttk would on press/key/wheel."""
widget = getattr(gate._cal_widgets, f"schedule_{which}_entry")
spin = cast("FakeSpinbox", widget)
handler = cast("Callable[[object], object]", spin.bindings[event])
return handler(None)
class TestSpinEvents:
def test_steps_only_while_editing(self, gate: MealGate) -> None:
gate._cal_vars.schedule.first.set("08:00")
# Locked: the event is swallowed and the value is unchanged.
assert _spin(gate, "first", "<<Increment>>") == "break"
assert gate._cal_vars.schedule.first.get() == "08:00"
gate._on_edit_or_save_schedule()
assert _spin(gate, "first", "<<Increment>>") == "break"
assert gate._cal_vars.schedule.first.get() == "08:15"
_spin(gate, "first", "<<Decrement>>")
_spin(gate, "first", "<<Decrement>>")
assert gate._cal_vars.schedule.first.get() == "07:45"
def test_an_off_grid_value_steps_to_its_neighbour(self, gate: MealGate) -> None:
gate._on_edit_or_save_schedule()
gate._cal_vars.schedule.last.set("19:23")
_spin(gate, "last", "<<Increment>>")
assert gate._cal_vars.schedule.last.get() == "19:30"
def test_the_last_field_uses_its_own_clamp(self, gate: MealGate) -> None:
gate._on_edit_or_save_schedule()
gate._cal_vars.schedule.last.set("00:15")
_spin(gate, "last", "<<Decrement>>")
assert gate._cal_vars.schedule.last.get() == "00:15"
class TestStateToggle:
def test_one_dead_widget_does_not_skip_the_rest(self, gate: MealGate) -> None:
"""A vanished monitor's spinbox must not leave the others locked."""
widgets = gate._cal_widgets
dead = _FakeTclError("dead")
with patch.object(widgets.schedule_first_entry, "configure", side_effect=dead):
gate._set_schedule_entry_state("normal")
assert widgets.schedule_last_entry.configured["state"] == "normal"
assert widgets.schedule_count_entry.configured["state"] == "normal"

View File

@ -54,10 +54,10 @@ class TestDashboard:
gate._pending = []
gate._refresh_slot_header()
assert "All meals logged" in gate._vars.slot_header.get()
gate._pending = [8]
gate._pending = [480]
gate._refresh_slot_header()
assert "Log your" in gate._vars.slot_header.get()
gate._pending = [8, 12]
gate._pending = [480, 720]
gate._refresh_slot_header()
assert "remaining" in gate._vars.slot_header.get()
@ -74,14 +74,14 @@ class TestSlotWalk:
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
"""A demo gate tags logs with no real slot."""
gate._pending = [8]
gate._pending = [480]
assert gate._slot_for_log() is None
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
"""A production gate tags logs with the current slot."""
gate.demo_mode = False
gate._pending = [12]
assert gate._slot_for_log() == 12
gate._pending = [720]
assert gate._slot_for_log() == 720
def test_clear_inputs_resets_the_form(self, gate: MealGate) -> None:
"""Clearing between slots empties the description and the macros."""
@ -93,7 +93,7 @@ class TestSlotWalk:
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
"""Finishing the final slot triggers unlock."""
gate._pending = [20]
gate._pending = [1200]
with patch.object(gate, "_unlock") as unlock:
gate._finish_slot("done")
unlock.assert_called_once()
@ -126,7 +126,7 @@ class TestFetchFromSync:
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
"""A failed pull shows the reason and leaves pending slots intact."""
gate.demo_mode = False
gate._pending = [8, 12]
gate._pending = [480, 720]
with patch.object(
_gatelock_delivery,
"pull_peer_logs",
@ -134,52 +134,52 @@ class TestFetchFromSync:
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [8, 12]
assert gate._pending == [480, 720]
assert "still locked" in gate._vars.status.get()
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
"""A clean pull that satisfies nothing reports so and keeps the slots."""
gate.demo_mode = False
gate._pending = [8, 12]
gate._pending = [480, 720]
with (
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
patch.object(_gatelock_delivery, "due_slots", return_value=(8, 12)),
patch.object(_gatelock_delivery, "due_slots", return_value=(480, 720)),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [8, 12]
assert gate._pending == [480, 720]
assert "No new meals" in gate._vars.status.get()
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
"""One slot pulled in leaves the rest; the window advances (singular)."""
gate.demo_mode = False
gate._pending = [8, 12]
gate._pending = [480, 720]
with (
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
patch.object(_gatelock_delivery, "due_slots", return_value=(12,)),
patch.object(_gatelock_delivery, "due_slots", return_value=(720,)),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [12]
assert gate._pending == [720]
assert "Pulled 1 meal " in gate._vars.status.get()
def test_partial_plural_wording(self, gate: MealGate) -> None:
"""Two slots pulled in uses the plural 'meals'."""
gate.demo_mode = False
gate._pending = [8, 12, 16]
gate._pending = [480, 720, 960]
with (
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
patch.object(_gatelock_delivery, "due_slots", return_value=(16,)),
patch.object(_gatelock_delivery, "due_slots", return_value=(960,)),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [16]
assert gate._pending == [960]
assert "Pulled 2 meals" in gate._vars.status.get()
def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
"""When the pull satisfies every pending slot, the gate unlocks."""
gate.demo_mode = False
gate._pending = [8, 12]
gate._pending = [480, 720]
with (
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
patch.object(_gatelock_delivery, "due_slots", return_value=()),

View File

@ -8,21 +8,15 @@ touched the first surface fails here and nowhere else.
from __future__ import annotations
from contextlib import ExitStack
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
from diet_guard import _gatelock_calendar
from diet_guard._gatelock import MealGate
from diet_guard.tests.conftest import (
_FAKE_TK,
_FAKE_TTK,
_GATE_TK_MODULES,
TWO_OUTPUTS,
)
from diet_guard.tests._gate_fixtures import fake_tk
from diet_guard.tests.conftest import TWO_OUTPUTS
if TYPE_CHECKING:
from collections.abc import Iterator
@ -32,10 +26,10 @@ if TYPE_CHECKING:
def dual_gate(dual_output: None) -> Iterator[MealGate]:
"""A demo gate built across two live outputs."""
del dual_output
with ExitStack() as stack:
for module in _GATE_TK_MODULES:
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
# The shared patch set, never a hand-picked copy: a copy silently misses
# the next module that starts building widgets (the schedule row's ttk
# time fields were exactly that).
with fake_tk():
yield MealGate(demo_mode=True)

View File

@ -24,6 +24,7 @@ from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks
from diet_guard._gatelock_kuchnia import DeliveryResult
from diet_guard._kuchnia_parse import Dish
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slot_wire import entry_slot_minute
from diet_guard._slots import day_slots
from diet_guard._state import log_meal, now_local
from diet_guard._state_today import today_entries
@ -89,10 +90,11 @@ def delivered(*dishes: Dish) -> DeliveryResult:
def logged() -> dict[int, list[str]]:
"""Today's on-disk log as slot -> descriptions."""
"""Today's on-disk log as recorded slot minute -> descriptions."""
by_slot: dict[int, list[str]] = {}
for entry in today_entries():
by_slot.setdefault(int(entry["slot"]), []).append(str(entry["desc"]))
slot = entry_slot_minute(entry) or 0
by_slot.setdefault(slot, []).append(str(entry["desc"]))
return by_slot
@ -104,7 +106,7 @@ def propose(h: Harness) -> None:
def test_default_schedule_precondition() -> None:
# Every expectation below assumes the redirected (default) schedule.
assert day_slots(current_schedule()) == (8, 12, 16, 20)
assert day_slots(current_schedule()) == (480, 720, 960, 1200)
class TestFirstClick:
@ -160,7 +162,7 @@ class TestFirstClick:
assert h.label.get() == FILL_LABEL
def test_every_slot_already_logged(self) -> None:
for slot in (8, 12, 16, 20):
for slot in (480, 720, 960, 1200):
log_meal(f"mine {slot}", _nutrition(), slot)
h = Harness()
with fetching(delivered(*FOUR)):
@ -169,7 +171,7 @@ class TestFirstClick:
assert h.label.get() == FILL_LABEL
def test_the_proposal_writes_nothing(self) -> None:
log_meal("my sandwich", _nutrition(), 12)
log_meal("my sandwich", _nutrition(), 720)
h = Harness()
with fetching(delivered(*FOUR)):
propose(h)
@ -178,23 +180,23 @@ class TestFirstClick:
"Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, "
"20:00 Kolacja — click Confirm."
)
assert logged() == {12: ["my sandwich"]}
assert logged() == {720: ["my sandwich"]}
h.on_logged.assert_not_called()
class TestConfirm:
def test_confirm_fills_only_the_empty_slots(self) -> None:
log_meal("my sandwich", _nutrition(), 12)
log_meal("my sandwich", _nutrition(), 720)
h = Harness()
with fetching(delivered(*FOUR)) as start:
propose(h)
h.flow.click()
assert start.call_count == 1 # the confirm did not refetch
assert logged() == {
8: ["Owsianka"],
12: ["my sandwich"],
16: ["Obiad"],
20: ["Kolacja"],
480: ["Owsianka"],
720: ["my sandwich"],
960: ["Obiad"],
1200: ["Kolacja"],
}
assert h.status == "Logged 3 catering dish(es)."
h.on_logged.assert_called_once_with()
@ -207,7 +209,7 @@ class TestConfirm:
with fetching(delivered(*five)):
propose(h)
h.flow.click()
assert logged()[8] == ["Owsianka", "Wrap"]
assert logged()[480] == ["Owsianka", "Wrap"]
assert h.status == "Logged 5 catering dish(es)."
def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None:
@ -215,16 +217,16 @@ class TestConfirm:
with fetching(delivered(*FOUR)):
propose(h)
assert h.label.get() == "✓ Confirm (4)"
log_meal("toast", _nutrition(), 8)
log_meal("toast", _nutrition(), 480)
h.flow.click()
assert logged()[8] == ["toast"]
assert logged()[480] == ["toast"]
assert h.status == "Logged 3 catering dish(es)."
def test_every_slot_taken_between_the_clicks(self) -> None:
h = Harness()
with fetching(delivered(*FOUR)):
propose(h)
for slot in (8, 12, 16, 20):
for slot in (480, 720, 960, 1200):
log_meal(f"mine {slot}", _nutrition(), slot)
h.flow.click()
assert h.status == "Every slot already has a meal — nothing to fill."
@ -240,10 +242,8 @@ class TestConfirm:
with patch.object(_gatelock_fillall, "now_local", return_value=tomorrow):
h.flow.click()
assert start.call_count == 2
assert start.call_args.args == (
_gatelock_fillall._refresh_delivery,
tomorrow.date(),
)
refetch = (_gatelock_fillall._refresh_delivery, tomorrow.date())
assert start.call_args.args == refetch
assert logged() == {}
h.on_logged.assert_not_called()
assert h.label.get() == FILL_LABEL

View File

@ -25,7 +25,7 @@ from diet_guard._gatelock_layout import build_layout
from diet_guard._gatelock_mealflow import _UNLOCK_DELAY_MS
from diet_guard._gatelock_ui import GateCallbacks, make_vars
from diet_guard._state import log_meal, now_local
from diet_guard._state_today import today_entries
from diet_guard._state_today import logged_slots_today
from diet_guard.tests._gate_fixtures import fake_tk
from diet_guard.tests.conftest import _nutrition
from diet_guard.tests.test_gatelock_fillall import (
@ -50,7 +50,7 @@ def _due_from_log(pending: list[int]) -> Callable[[], tuple[int, ...]]:
"""``due_slots`` as the log on disk says, so an unlock needs a real write."""
def due() -> tuple[int, ...]:
taken = {entry.get("slot") for entry in today_entries()}
taken = logged_slots_today()
return tuple(slot for slot in pending if slot not in taken)
return due
@ -86,29 +86,29 @@ class TestTheGateDrivesTheFlow:
def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None:
gate.demo_mode = False # read once, when the flow is first built
gate._pending = [8, 12]
gate._pending = [480, 720]
# Fill all also banks the dishes for one-at-a-time offering; the
# unlock line must not then call them "still delivered".
gate._delivery_pending = FOUR
due = _due_from_log([8, 12])
due = _due_from_log([480, 720])
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
_fill(gate, delivered(*FOUR))
status = gate._vars.status.get()
assert status == "Filled from catering — all meals logged, unlocking…"
assert gate._pending == []
gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close)
assert sorted(logged()) == [8, 12, 16, 20]
assert sorted(logged()) == [480, 720, 960, 1200]
assert gate._vars.fill_label.get() == FILL_LABEL
def test_a_partial_fill_advances_instead_of_unlocking(self, gate: MealGate) -> None:
gate.demo_mode = False
gate._pending = [8, 12]
due = _due_from_log([8, 12])
gate._pending = [480, 720]
due = _due_from_log([480, 720])
# Two dishes spread onto 08:00 and 16:00: 12:00 is still owed.
two = (dish("Owsianka", 1), dish("Obiad", 2))
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
_fill(gate, delivered(*two))
assert gate._pending == [12]
assert gate._pending == [720]
assert "Pulled 1 meal " in gate._vars.status.get()
assert "unlocking" not in gate._vars.status.get()

View File

@ -194,14 +194,14 @@ class TestRecord:
def test_demo_logs_without_slot(self, gate: MealGate) -> None:
"""A demo record banks the food but tags no real slot."""
gate._pending = [8]
gate._pending = [480]
with patch.object(_gatelock_mealflow, "log_meal") as log:
gate._record("apple", _nutrition(95, 100))
assert log.call_args.args[2] is None
def test_last_slot_unlocks(self, gate: MealGate) -> None:
"""Recording the final pending slot triggers the unlock."""
gate._pending = [8]
gate._pending = [480]
with (
patch.object(_gatelock_mealflow, "log_meal"),
patch.object(_gatelock_mealflow, "remember_food"),
@ -212,13 +212,13 @@ class TestRecord:
def test_more_slots_continue(self, gate: MealGate) -> None:
"""With slots remaining, the form clears and prompts the next."""
gate._pending = [8, 12]
gate._pending = [480, 720]
with (
patch.object(_gatelock_mealflow, "log_meal"),
patch.object(_gatelock_mealflow, "remember_food"),
):
gate._record("apple", _nutrition(95, 100))
assert gate._pending == [12]
assert gate._pending == [720]
assert "next meal" in gate._vars.status.get()
def test_unlock_schedules_close(self, gate: MealGate) -> None:

View File

@ -1,15 +1,18 @@
"""Tests for the gate History tab's meal-schedule row.
Covers :mod:`._gatelock_scheduleedit`. The functional fake ``tk`` widgets and
the ``gate`` fixture live in ``conftest.py``, shared with the other gate
tests.
Covers :mod:`._gatelock_scheduleedit` and the row's widgets in
:mod:`._gatelock_calendar_schedule`. The functional fake ``tk``/``ttk``
widgets and the ``gate`` fixture live in ``conftest.py``, shared with the
other gate tests.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._gatelock_scheduleedit import schedule_summary
import pytest
from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
@ -18,21 +21,30 @@ if TYPE_CHECKING:
def _type(gate: MealGate, first: str, last: str, count: str) -> None:
"""Fill the three schedule entries as the user would."""
"""Fill the three schedule fields as the user would."""
gate._cal_vars.schedule.first.set(first)
gate._cal_vars.schedule.last.set(last)
gate._cal_vars.schedule.count.set(count)
def _status(gate: MealGate) -> str:
return gate._cal_vars.schedule.status.get()
class TestScheduleSummary:
"""The derived-times label."""
def test_lists_every_checkpoint(self) -> None:
"""The label spells out the hours the schedule derives."""
assert schedule_summary(MealSchedule(8, 20, 5)) == (
"""The label spells out the times the schedule derives."""
assert schedule_summary(MealSchedule(480, 1200, 5)) == (
"08:00 11:00 14:00 17:00 20:00"
)
def test_an_off_hour_schedule_shows_its_minutes(self) -> None:
assert schedule_summary(MealSchedule(435, 1140, 5)) == (
"07:15 10:15 13:15 16:00 19:00"
)
class TestEditToggle:
"""The Edit/Save button's two states."""
@ -47,71 +59,95 @@ class TestEditToggle:
def test_second_click_persists_and_relocks(self, gate: MealGate) -> None:
"""Clicking Save validates, writes, and returns to read-only."""
gate._on_edit_or_save_schedule()
_type(gate, "8", "20", "5")
_type(gate, "07:15", "19:00", "5")
gate._on_edit_or_save_schedule()
assert not gate._cal_editing_schedule
assert current_schedule() == MealSchedule(8, 20, 5)
assert gate._cal_vars.schedule.status.get() == "Saved."
assert current_schedule() == MealSchedule(435, 1140, 5)
assert _status(gate) == "Saved."
assert gate._cal_vars.schedule.times.get().startswith("07:15 10:15")
def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None:
"""Typing ``8`` means 08:00, as it did before minutes existed."""
_type(gate, "8", "20", "5")
assert gate._save_schedule_entry()
assert current_schedule() == MealSchedule(480, 1200, 5)
assert gate._cal_vars.schedule.first.get() == "08:00"
def test_a_failed_save_leaves_editing_open(self, gate: MealGate) -> None:
"""A bad value can be corrected rather than silently discarded."""
gate._on_edit_or_save_schedule()
_type(gate, "20", "8", "4")
_type(gate, "20:00", "08:00", "4")
gate._on_edit_or_save_schedule()
assert gate._cal_editing_schedule
assert current_schedule() == DEFAULT_SCHEDULE
def test_the_spinboxes_lock_as_disabled_not_readonly(self, gate: MealGate) -> None:
"""A ``readonly`` ttk spinbox still spins, editing a locked row."""
widgets = gate._cal_widgets
gate._on_edit_or_save_schedule()
assert widgets.schedule_first_entry.configured["state"] == "normal"
assert widgets.schedule_count_entry.configured["state"] == "normal"
_type(gate, "07:15", "19:00", "5")
gate._on_edit_or_save_schedule()
assert widgets.schedule_first_entry.configured["state"] == "disabled"
assert widgets.schedule_last_entry.configured["state"] == "disabled"
assert widgets.schedule_count_entry.configured["state"] == "readonly"
class TestValidation:
"""Rejected input, with the reason shown rather than silently clamped."""
def test_rejects_non_numeric(self, gate: MealGate) -> None:
"""Letters in an hour field are refused, not coerced."""
_type(gate, "eight", "20", "4")
@pytest.mark.parametrize("first", ["eight", "7:5", "24:00", "07:60", ""])
def test_rejects_a_time_that_is_not_hhmm(self, gate: MealGate, first: str) -> None:
_type(gate, first, "20:00", "4")
assert not gate._save_schedule_entry()
assert "whole hours" in gate._cal_vars.schedule.status.get()
assert "HH:MM" in _status(gate)
def test_rejects_a_backwards_window(self, gate: MealGate) -> None:
"""The last meal must come after the first."""
_type(gate, "20", "8", "4")
def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None:
_type(gate, "08:00", "20:00", "five")
assert not gate._save_schedule_entry()
assert "before the last" in gate._cal_vars.schedule.status.get()
assert "meals per day as a number" in _status(gate)
def test_rejects_an_out_of_range_hour(self, gate: MealGate) -> None:
"""Hours outside 0-23 are refused."""
_type(gate, "8", "25", "4")
@pytest.mark.parametrize(
("first", "last", "count", "reason"),
[
("20:00", "08:00", "4", "at least 15 min after"),
("08:00", "08:14", "2", "at least 15 min after"),
("08:00", "20:00", "1", "Meals per day must be 2-6."),
("08:00", "20:00", "9", "Meals per day must be 2-6."),
("08:00", "08:45", "5", "Too many meals"),
],
)
def test_rejects_what_normalize_would_silently_change(
self, gate: MealGate, first: str, last: str, count: str, reason: str
) -> None:
_type(gate, first, last, count)
assert not gate._save_schedule_entry()
assert "0-23" in gate._cal_vars.schedule.status.get()
assert reason in _status(gate)
assert current_schedule() == DEFAULT_SCHEDULE
def test_rejects_too_few_meals(self, gate: MealGate) -> None:
"""One meal a day is below the supported range."""
_type(gate, "8", "20", "1")
def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None:
"""15 min holds two meals; 45 min holds four (one per quarter hour)."""
_type(gate, "08:00", "08:45", "4")
assert not gate._save_schedule_entry()
assert "Meals per day" in gate._cal_vars.schedule.status.get()
assert gate._save_schedule_entry()
assert current_schedule().slots() == (480, 495, 510, 525)
def test_rejects_too_many_meals(self, gate: MealGate) -> None:
"""More than the cap is refused."""
_type(gate, "8", "20", "9")
assert not gate._save_schedule_entry()
assert "Meals per day" in gate._cal_vars.schedule.status.get()
def test_rejects_more_meals_than_the_window_holds(self, gate: MealGate) -> None:
"""A narrow window cannot hold six distinct whole-hour checkpoints.
Without this the derivation would round two meals onto the same hour,
and a duplicate slot silently drops a checkpoint.
"""
_type(gate, "8", "11", "6")
assert not gate._save_schedule_entry()
assert "Too many meals" in gate._cal_vars.schedule.status.get()
def test_an_accepted_schedule_is_stored_unchanged(self) -> None:
"""No rule here is looser than ``normalized()``: what passes is kept."""
for first in range(0, 1440, 37):
for last in range(first + 15, 1440, 53):
for count in range(2, 7):
schedule = MealSchedule(first, last, count)
if schedule_problem(schedule) is None:
assert schedule.normalized() == schedule
class TestDisplay:
@ -119,27 +155,27 @@ class TestDisplay:
def test_shows_the_stored_schedule(self, gate: MealGate) -> None:
"""The fields and the derived-times label reflect what is stored."""
gate._show_schedule(MealSchedule(7, 21, 3))
gate._show_schedule(MealSchedule(435, 1260, 3))
assert gate._cal_vars.schedule.first.get() == "7"
assert gate._cal_vars.schedule.last.get() == "21"
assert gate._cal_vars.schedule.first.get() == "07:15"
assert gate._cal_vars.schedule.last.get() == "21:00"
assert gate._cal_vars.schedule.count.get() == "3"
assert gate._cal_vars.schedule.times.get() == "07:00 14:00 21:00"
assert gate._cal_vars.schedule.times.get() == "07:15 14:15 21:00"
def test_a_refresh_does_not_clobber_an_open_edit(self, gate: MealGate) -> None:
"""Typing survives a calendar refresh landing mid-edit."""
gate._on_edit_or_save_schedule()
_type(gate, "6", "22", "6")
_type(gate, "06:00", "22:00", "6")
gate._refresh_calendar()
assert gate._cal_vars.schedule.first.get() == "6"
assert gate._cal_vars.schedule.first.get() == "06:00"
def test_a_refresh_shows_the_stored_schedule_when_idle(
self, gate: MealGate
) -> None:
"""Outside an edit the row tracks whatever is stored."""
record_schedule_change(MealSchedule(9, 19, 3))
record_schedule_change(MealSchedule(540, 1140, 3))
gate._refresh_calendar()
assert gate._cal_vars.schedule.first.get() == "9"
assert gate._cal_vars.schedule.first.get() == "09:00"
assert gate._cal_vars.schedule.times.get() == "09:00 14:00 19:00"

View File

@ -40,33 +40,40 @@ def _eat(desc: str, slot: int) -> None:
class TestFillPlan:
def test_an_empty_day_fills_every_slot(self) -> None:
plan = [_slotted("A", 8), _slotted("B", 12), _slotted("C", 20)]
plan = [_slotted("A", 480), _slotted("B", 720), _slotted("C", 1200)]
assert fill_plan(plan) == plan
def test_a_slot_with_a_meal_keeps_it(self) -> None:
_eat("apple", 12)
plan = fill_plan([_slotted("A", 8), _slotted("B", 12), _slotted("C", 20)])
assert [item.slot for item in plan] == [8, 20]
_eat("apple", 720)
plan = fill_plan([_slotted("A", 480), _slotted("B", 720), _slotted("C", 1200)])
assert [item.slot for item in plan] == [480, 1200]
def test_two_dishes_sharing_an_empty_slot_both_land(self) -> None:
# 5 dishes on 4 slots double up the first; occupancy is read once,
# before the batch, so the second dish is not refused by the first.
plan = fill_plan([_slotted("A", 8), _slotted("B", 8)])
plan = fill_plan([_slotted("A", 480), _slotted("B", 480)])
assert log_dishes(plan) == ["A", "B"]
def test_a_full_day_fills_nothing(self) -> None:
for slot in (8, 12):
for slot in (480, 720):
_eat("apple", slot)
assert fill_plan([_slotted("A", 8), _slotted("B", 12)]) == []
assert fill_plan([_slotted("A", 480), _slotted("B", 720)]) == []
def test_a_meal_near_a_slot_occupies_it(self) -> None:
# Logged at 08:15 (say, under an older schedule): it snaps to 08:00,
# the same rule the gate uses, so catering must not double that slot.
_eat("apple", 495)
plan = fill_plan([_slotted("A", 480), _slotted("B", 720)])
assert [item.slot for item in plan] == [720]
class TestCliFill:
def test_log_skips_a_taken_slot(self) -> None:
# Two dishes on the default four slots spread to 08:00 and 16:00.
_eat("apple", 8)
_eat("apple", 480)
dishes = [
replace(_slotted("Owsianka", 8).dish, priority=1),
replace(_slotted("Kaszotto", 16).dish, priority=2),
replace(_slotted("Owsianka", 480).dish, priority=1),
replace(_slotted("Kaszotto", 960).dish, priority=2),
]
lines: list[str] = []
with patch.object(

View File

@ -194,8 +194,8 @@ class TestRefreshOnce:
class TestLogDishes:
def test_logs_each_dish_into_its_slot(self) -> None:
chosen = [
SlottedDish(dish=_dish("A", kcal=100.0), slot=8),
SlottedDish(dish=_dish("B", kcal=200.0), slot=12),
SlottedDish(dish=_dish("A", kcal=100.0), slot=480),
SlottedDish(dish=_dish("B", kcal=200.0), slot=720),
]
assert log_dishes(chosen) == ["A", "B"]
entries = today_entries()
@ -203,33 +203,41 @@ class TestLogDishes:
assert today_total_kcal() == 300.0
def test_records_the_provenance(self) -> None:
log_dishes([SlottedDish(dish=_dish("A"), slot=8)])
log_dishes([SlottedDish(dish=_dish("A"), slot=480)])
(entry,) = today_entries()
assert entry["source"] == "kuchnia wikinga"
def test_a_second_run_logs_nothing(self) -> None:
chosen = [SlottedDish(dish=_dish("A"), slot=8)]
chosen = [SlottedDish(dish=_dish("A"), slot=480)]
log_dishes(chosen)
assert log_dishes(chosen) == []
assert len(today_entries()) == 1
def test_the_same_dish_in_a_different_slot_is_a_separate_meal(self) -> None:
log_dishes([SlottedDish(dish=_dish("A"), slot=8)])
assert log_dishes([SlottedDish(dish=_dish("A"), slot=12)]) == ["A"]
log_dishes([SlottedDish(dish=_dish("A"), slot=480)])
assert log_dishes([SlottedDish(dish=_dish("A"), slot=720)]) == ["A"]
assert len(today_entries()) == 2
def test_duplicates_within_one_batch_land_once(self) -> None:
# today_entries() is read once, before the loop; without reflecting the
# writes locally both copies would be logged.
chosen = [
SlottedDish(dish=_dish("A"), slot=8),
SlottedDish(dish=_dish("A"), slot=8),
SlottedDish(dish=_dish("A"), slot=480),
SlottedDish(dish=_dish("A"), slot=480),
]
assert log_dishes(chosen) == ["A"]
def test_matching_ignores_case_and_padding(self) -> None:
log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=8)])
assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=8)]) == []
log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=480)])
assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=480)]) == []
def test_dedup_compares_the_recorded_minute_not_the_snapped_slot(self) -> None:
# Parity with the app: dedup is (name, recorded minute). An 08:15 copy
# is a different key from 08:00 even though both snap to 08:00 --
# the fill flow is kept from doubling it by fill_plan's occupancy.
log_dishes([SlottedDish(dish=_dish("A"), slot=495)])
assert log_dishes([SlottedDish(dish=_dish("A"), slot=495)]) == []
assert log_dishes([SlottedDish(dish=_dish("A"), slot=480)]) == ["A"]
def test_logging_nothing_is_not_an_error(self) -> None:
assert log_dishes([]) == []

View File

@ -32,6 +32,7 @@ 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"
@ -151,6 +152,12 @@ def test_bank_record_numbers_are_floats(dishes: list[Dish]) -> None:
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]:
@ -168,7 +175,10 @@ def test_fill_plan_matches(
fill = fixture["expected"]["fill"]
assert fill["fill_plan"], "fixture carries no fill_plan cases"
for key, case in fill["fill_plan"].items():
today = [{"slot": slot} for slot in case["occupied"]]
# ``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]
@ -185,7 +195,8 @@ def test_log_dishes_matches(
fill = fixture["expected"]["fill"]
for key, case in fill["log_dishes"].items():
recorder = MagicMock()
monkeypatch.setattr(_kuchnia_log, "today_entries", lambda c=case: c["today"])
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]

View File

@ -137,18 +137,20 @@ def _dish(priority: int, name: str = "d") -> Dish:
class TestAssignSlots:
def test_follows_the_providers_priority_not_payload_order(self) -> None:
dishes = [_dish(3), _dish(1), _dish(2)]
assigned = assign_slots(dishes, (8, 12, 16))
assigned = assign_slots(dishes, (480, 720, 960))
assert [item.dish.name for item in assigned] == ["d1", "d2", "d3"]
assert [item.slot for item in assigned] == [8, 12, 16]
assert [item.slot for item in assigned] == [480, 720, 960]
def test_five_dishes_over_four_slots_double_up_the_earliest(self) -> None:
assigned = assign_slots([_dish(i) for i in range(1, 6)], (8, 12, 16, 20))
assert [item.slot for item in assigned] == [8, 8, 12, 16, 20]
assigned = assign_slots([_dish(i) for i in range(1, 6)], (480, 720, 960, 1200))
assert [item.slot for item in assigned] == [480, 480, 720, 960, 1200]
def test_five_dishes_over_five_slots_map_one_to_one(self) -> None:
# kuhy's real schedule; the catering plan is also five meals.
assigned = assign_slots([_dish(i) for i in range(1, 6)], (8, 11, 14, 17, 20))
assert [item.slot for item in assigned] == [8, 11, 14, 17, 20]
assigned = assign_slots(
[_dish(i) for i in range(1, 6)], (480, 660, 840, 1020, 1200)
)
assert [item.slot for item in assigned] == [480, 660, 840, 1020, 1200]
@pytest.mark.parametrize("count", range(1, 9))
@pytest.mark.parametrize("span", range(2, 7))
@ -170,8 +172,8 @@ class TestAssignSlots:
# Two dishes sharing a priority must not reshuffle between runs: an
# unstable order looks like a change and re-stamps every bank record.
dishes = [_dish(1, "b"), _dish(1, "a")]
first = [item.dish.name for item in assign_slots(dishes, (8, 12))]
second = [item.dish.name for item in assign_slots(dishes[::-1], (8, 12))]
first = [item.dish.name for item in assign_slots(dishes, (480, 720))]
second = [item.dish.name for item in assign_slots(dishes[::-1], (480, 720))]
assert first == second
@pytest.mark.parametrize(

View File

@ -135,7 +135,7 @@ class TestTheGateAdvancesTheQueue:
a dead letter: the form clears and the remaining dishes are stranded
behind another button click, which is an unguarded network walk each.
"""
gate._pending = [8, 12]
gate._pending = [480, 720]
gate._delivery_pending = (_dish("Second course"),)
with (
patch.object(_gatelock_mealflow, "log_meal"),

View File

@ -34,8 +34,8 @@ class TestGetStatus:
_mcp_read, "today_total_macros", return_value=(30.0, 50.0, 12.0)
),
patch.object(_mcp_read, "consumption_band", return_value="on track"),
patch.object(_mcp_read, "due_slots", return_value=(12,)),
patch.object(_mcp_read, "logged_slots_today", return_value={8}),
patch.object(_mcp_read, "due_slots", return_value=(720,)),
patch.object(_mcp_read, "logged_slots_today", return_value={480}),
patch.object(_mcp_read, "current_slot", return_value=12),
patch.object(_mcp, "now_local"),
patch.object(_mcp_read, "slot_label", return_value="12:00"),
@ -46,7 +46,7 @@ class TestGetStatus:
assert out["consumption_band"] == "on track"
assert out["budget_initialized"] is True
assert out["due_slots"] == ["12:00"]
assert out["logged_slots"] == [8]
assert out["logged_slots"] == [480]
assert out["current_slot"] == 12
def test_no_budget_hides_band(self) -> None:
@ -92,7 +92,8 @@ class TestListToday:
view = out["entries"][0]
assert view["desc"] == "big mac"
assert view["kcal"] == 550.0
assert view["slot"] == 12
# The legacy on-disk hour is reported as a minute, like every slot.
assert view["slot"] == 720
assert "hmac" not in view
def test_empty(self) -> None:
@ -104,14 +105,13 @@ class TestListToday:
class TestGetSlots:
def test_lists_slots_and_current(self) -> None:
with (
patch.object(_mcp_read, "day_slots", return_value=(8, 12, 16, 20)),
patch.object(_mcp_read, "current_slot", return_value=16),
patch.object(_mcp_read, "day_slots", return_value=(435, 720, 960, 1200)),
patch.object(_mcp_read, "current_slot", return_value=960),
patch.object(_mcp, "now_local"),
patch.object(_mcp_read, "slot_label", side_effect=lambda s: f"{s:02d}:00"),
):
out = _mcp.get_slots()
assert out["current_slot"] == 16
assert out["day_slots"][0] == {"hour": 8, "label": "08:00"}
assert out["current_slot"] == 960
assert out["day_slots"][0] == {"minute": 435, "label": "07:15"}
assert len(out["day_slots"]) == 4
@ -141,7 +141,7 @@ class TestLogMealGate:
)
record.assert_not_called()
assert out["preview"] is True
assert out["target_slot"] == 12
assert out["target_slot"] == 720 # the legacy int hour, as minutes
assert out["resolved"]["kcal"] == 250.0
assert out["confirm_required"] is True
@ -149,7 +149,7 @@ class TestLogMealGate:
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "ManualMacros") as manual,
patch.object(_mcp, "slot_for_log", return_value=8),
patch.object(_mcp, "slot_for_log", return_value=480),
patch.object(_mcp, "now_local"),
patch.object(_mcp, "record_meal") as record,
):
@ -157,7 +157,7 @@ class TestLogMealGate:
manual.assert_not_called()
record.assert_not_called()
assert out["preview"] is True
assert out["target_slot"] == 8
assert out["target_slot"] == 480
def test_confirm_applies_signed(self) -> None:
entry = {"desc": "apple", "hmac": "sig"}
@ -169,7 +169,7 @@ class TestLogMealGate:
record.assert_called_once()
assert out["applied"] is True
assert out["signed"] is True
assert out["target_slot"] == 8
assert out["target_slot"] == 480
def test_confirm_applies_unsigned(self) -> None:
entry = {"desc": "apple"}

View File

@ -0,0 +1,65 @@
"""The MCP ``log_meal`` tool's ``slot`` argument: ``"HH:MM"`` or a legacy hour.
Kept apart from ``test_mcp.py`` for the repo's file-length cap. These run
against the real (redirected) schedule store rather than a patched
``day_slots``, so "is this one of today's slots?" is answered the way the gate
answers it.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from diet_guard import _mcp
from diet_guard._estimator import Nutrition
from diet_guard._meal_schedule import MealSchedule
from diet_guard._meal_schedule_store import record_schedule_change
_MEAL = Nutrition(250.0, 10.0, 20.0, 5.0, 150.0, "manual")
def _preview(slot: int | str) -> dict[str, object]:
"""Preview a log with ``slot`` (never writes: ``confirm`` stays False)."""
with patch.object(_mcp, "resolve_nutrition", return_value=_MEAL):
return _mcp.log_meal("apple", slot=slot)
class TestSlotArgument:
def test_an_hhmm_string_names_an_off_hour_slot(self) -> None:
record_schedule_change(MealSchedule(435, 1140, 5))
assert _preview("07:15")["target_slot"] == 435
def test_a_legacy_int_hour_is_read_as_that_hour(self) -> None:
"""``12`` is 12:00 -- never 00:12, which is what a bare minute would be."""
assert _preview(12)["target_slot"] == 720
def test_an_hh_string_is_a_whole_hour(self) -> None:
assert _preview("20")["target_slot"] == 1200
@pytest.mark.parametrize(
("slot", "reason"),
[
("13:00", "13:00 is not a meal slot today"),
(13, "13:00 is not a meal slot today"),
("noon", "invalid time"),
(25, "is not an hour 0-23"),
(-1, "is not an hour 0-23"),
(True, "is not an hour 0-23"),
],
)
def test_anything_but_one_of_todays_slots_is_refused(
self, slot: int | str, reason: str
) -> None:
"""A clear reason, and nothing resolved or written."""
with patch.object(_mcp, "record_meal") as record:
out = _preview(slot)
record.assert_not_called()
assert out["ok"] is False
assert reason in str(out["reason"])
def test_the_refusal_lists_the_valid_slots(self) -> None:
record_schedule_change(MealSchedule(435, 1140, 5))
reason = str(_preview("07:00")["reason"])
assert "07:15, 10:15, 13:15, 16:00, 19:00" in reason

View File

@ -30,16 +30,32 @@ class TestEntryJson:
"""Encoding then decoding preserves the schedule and the stamp."""
entry = store.ScheduleEntry(
effective_from="2026-08-16",
schedule=MealSchedule(8, 20, 5),
schedule=MealSchedule(480, 1200, 5),
edited_at="2026-08-16T12:00:00+00:00",
)
assert store.entry_from_json("2026-08-16", store.entry_to_json(entry)) == entry
def test_a_whole_hour_schedule_encodes_exactly_as_before(self) -> None:
"""No ``fm``/``lm`` for whole hours: an older build reads it unchanged."""
entry = store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t")
assert store.entry_to_json(entry) == {"f": 8, "l": 20, "n": 5, "t": "t"}
def test_an_off_hour_schedule_round_trips_its_minutes(self) -> None:
"""07:15-19:00 keeps its quarter hour; ``f`` still carries the hour."""
entry = store.ScheduleEntry("2026-08-16", MealSchedule(435, 1140, 5), "t")
raw = store.entry_to_json(entry)
assert raw == {"f": 7, "l": 19, "n": 5, "fm": 435, "t": "t"}
assert store.entry_from_json("2026-08-16", raw) == entry
def test_rejects_a_bool_field(self) -> None:
"""``true`` is not an int on the Dart side, so it is not one here."""
assert store.entry_from_json("2026-08-16", {"f": True, "l": 20, "n": 4}) is None
def test_normalizes_on_the_way_in(self) -> None:
"""A peer's out-of-range schedule is clamped, not trusted verbatim."""
entry = store.entry_from_json("2026-08-16", {"f": 8, "l": 20, "n": 99})
assert entry is not None
assert entry.schedule == MealSchedule(8, 20, 6)
assert entry.schedule == MealSchedule(480, 1200, 6)
def test_missing_timestamp_falls_back_to_the_epoch(self) -> None:
"""An entry with no stamp still parses, losing only its ordering."""
@ -62,7 +78,7 @@ class TestHistoryJson:
def test_round_trips_and_sorts(self) -> None:
"""Entries come back ascending regardless of insertion order."""
entries = (
store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"),
store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t1"),
store.ScheduleEntry("2026-01-01", DEFAULT_SCHEDULE, "t0"),
)
parsed = store.history_from_json(store.history_to_json(entries))
@ -90,17 +106,21 @@ class TestScheduleForDay:
def test_defaults_when_the_history_is_silent(self) -> None:
"""A day before any entry uses the default, not the newest entry."""
entries = (store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t"),)
entries = (store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t"),)
assert store.schedule_for_day(entries, "2026-08-15") == DEFAULT_SCHEDULE
def test_uses_the_newest_applicable_entry(self) -> None:
"""The latest entry effective on or before the day wins."""
entries = (
store.ScheduleEntry("2026-01-01", MealSchedule(8, 20, 4), "t0"),
store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"),
store.ScheduleEntry("2026-01-01", MealSchedule(480, 1200, 4), "t0"),
store.ScheduleEntry("2026-08-16", MealSchedule(480, 1200, 5), "t1"),
)
assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(
480, 1200, 5
)
assert store.schedule_for_day(entries, "2026-05-01") == MealSchedule(
480, 1200, 4
)
assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(8, 20, 5)
assert store.schedule_for_day(entries, "2026-05-01") == MealSchedule(8, 20, 4)
class TestUpsert:
@ -108,16 +128,16 @@ class TestUpsert:
def test_appends_a_new_day(self) -> None:
"""An edit on a fresh day adds an entry."""
entries = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16"))
entries = store.upsert((), MealSchedule(480, 1200, 5), _at("2026-08-16"))
assert len(entries) == 1
assert entries[0].effective_from == "2026-08-16"
def test_replaces_a_same_day_re_edit(self) -> None:
"""Editing twice in one day leaves one entry, not two."""
first = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16"))
second = store.upsert(first, MealSchedule(9, 21, 3), _at("2026-08-16"))
first = store.upsert((), MealSchedule(480, 1200, 5), _at("2026-08-16"))
second = store.upsert(first, MealSchedule(540, 1260, 3), _at("2026-08-16"))
assert len(second) == 1
assert second[0].schedule == MealSchedule(9, 21, 3)
assert second[0].schedule == MealSchedule(540, 1260, 3)
class TestSeedDefault:
@ -144,10 +164,10 @@ class TestPersistence:
def test_round_trips_through_disk(self) -> None:
"""What is written is what is read back."""
store.record_schedule_change(MealSchedule(8, 20, 5), when=_at("2026-08-16"))
store.record_schedule_change(MealSchedule(480, 1200, 5), when=_at("2026-08-16"))
assert store.schedule_for_day(
store.load_entries(), "2026-08-16"
) == MealSchedule(8, 20, 5)
) == MealSchedule(480, 1200, 5)
def test_recording_grandfathers_earlier_days(self) -> None:
"""Switching to five meals leaves past days on the four-meal schedule.
@ -156,10 +176,12 @@ class TestPersistence:
past day would adopt the schedule chosen today and look like it had
missed a checkpoint.
"""
store.record_schedule_change(MealSchedule(8, 20, 5), when=_at("2026-08-16"))
store.record_schedule_change(MealSchedule(480, 1200, 5), when=_at("2026-08-16"))
entries = store.load_entries()
assert store.schedule_for_day(entries, "2020-01-01") == DEFAULT_SCHEDULE
assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(8, 20, 5)
assert store.schedule_for_day(entries, "2026-08-16") == MealSchedule(
480, 1200, 5
)
def test_corrupt_file_degrades_to_the_default(self) -> None:
"""Unparsable JSON is treated as "no history", never a crash."""
@ -189,8 +211,8 @@ class TestPersistence:
def test_record_defaults_to_now(self) -> None:
"""Omitting ``when`` stamps the edit with the current time."""
store.record_schedule_change(MealSchedule(7, 19, 3))
store.record_schedule_change(MealSchedule(420, 1140, 3))
today = datetime.now(tz=UTC).astimezone().date().isoformat()
assert store.schedule_for_day(store.load_entries(), today) == MealSchedule(
7, 19, 3
420, 1140, 3
)

View File

@ -81,25 +81,25 @@ class TestLogAndTotals:
def test_log_and_total(self) -> None:
"""A logged meal counts toward the day's calories."""
log_meal("toast", _nut(150), slot=8)
log_meal("toast", _nut(150), slot=480)
assert today_total_kcal() == 150.0
def test_entry_carries_signature(self) -> None:
"""With a key present, the stored entry is signed."""
entry = log_meal("toast", _nut(150), slot=8)
entry = log_meal("toast", _nut(150), slot=480)
assert "hmac" in entry
def test_unsigned_when_no_key(self) -> None:
"""With no key, the entry is written unsigned and still read back."""
with patch.object(_state, "compute_entry_hmac", return_value=None):
log_meal("toast", _nut(150), slot=8)
log_meal("toast", _nut(150), slot=480)
assert "hmac" not in _raw()[next(iter(_raw()))][0]
assert today_total_kcal() == 150.0
def test_macros_sum(self) -> None:
"""today_total_macros sums protein/carbs/fat across entries."""
log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=8)
log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=12)
log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=480)
log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=720)
assert today_total_macros() == (16.0, 45.0, 11.0)
def test_slotless_entry_counts_calories_only(self) -> None:
@ -141,12 +141,12 @@ class TestVerification:
def test_valid_entry_kept(self) -> None:
"""A correctly signed entry survives verification."""
log_meal("toast", _nut(150), slot=8)
log_meal("toast", _nut(150), slot=480)
assert today_entries()
def test_tampered_entry_dropped(self) -> None:
"""An edited calorie value invalidates the signature and is dropped."""
log_meal("toast", _nut(150), slot=8)
log_meal("toast", _nut(150), slot=480)
raw = _raw()
day = next(iter(raw))
raw[day][0]["kcal"] = 999
@ -184,19 +184,19 @@ class TestIdAndComponents:
def test_entry_has_id(self) -> None:
"""Every logged entry carries a UUID id."""
entry = log_meal("toast", _nut(150), slot=8)
entry = log_meal("toast", _nut(150), slot=480)
assert isinstance(entry["id"], str)
assert entry["id"]
def test_ids_are_unique(self) -> None:
"""Two entries never collide on id."""
first = log_meal("a", _nut(1), slot=8)
second = log_meal("b", _nut(1), slot=12)
first = log_meal("a", _nut(1), slot=480)
second = log_meal("b", _nut(1), slot=720)
assert first["id"] != second["id"]
def test_components_omitted_by_default(self) -> None:
"""A single-food entry carries no components field."""
entry = log_meal("toast", _nut(150), slot=8)
entry = log_meal("toast", _nut(150), slot=480)
assert "components" not in entry
def test_components_carried_through(self) -> None:
@ -211,7 +211,7 @@ class TestIdAndComponents:
"grams": 100.0,
}
]
entry = log_meal("dinner", _nut(165), slot=20, components=parts)
entry = log_meal("dinner", _nut(165), slot=1200, components=parts)
assert entry["components"] == parts
@ -221,7 +221,7 @@ class TestFutureLogging:
def test_buckets_under_the_future_date(self) -> None:
"""A future ``when`` places the entry under that date, not today."""
future = now_local() + timedelta(days=1)
entry = log_meal("future meal", _nut(200), slot=12, when=future)
entry = log_meal("future meal", _nut(200), slot=720, when=future)
future_day = future.date().isoformat()
assert today_total_kcal() == 0.0
assert load_log()[future_day][0]["desc"] == "future meal"
@ -231,5 +231,5 @@ class TestFutureLogging:
"""A ``when`` before now raises ValueError and writes nothing."""
past = now_local() - timedelta(days=1)
with pytest.raises(ValueError, match="in the past"):
log_meal("late", _nut(50), slot=8, when=past)
log_meal("late", _nut(50), slot=480, when=past)
assert load_log() == {}

View File

@ -52,8 +52,8 @@ class TestUndo:
def test_undo_leaves_earlier_entries(self) -> None:
"""Undo tombstones only the last entry when others remain."""
log_meal("a", _nut(100), slot=8)
log_meal("b", _nut(200), slot=12)
log_meal("a", _nut(100), slot=480)
log_meal("b", _nut(200), slot=720)
removed = undo_last_today()
assert removed is not None
assert removed["desc"] == "b"
@ -61,7 +61,7 @@ class TestUndo:
def test_undo_tombstones_in_place(self) -> None:
"""Undoing the only entry keeps it on disk, marked deleted."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
undo_last_today()
raw = _raw()
day = next(iter(raw))
@ -70,7 +70,7 @@ class TestUndo:
def test_undo_tombstone_excluded_from_reads(self) -> None:
"""A tombstoned entry no longer counts toward totals or slots."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
undo_last_today()
assert today_total_kcal() == 0.0
assert today_entries() == []
@ -78,7 +78,7 @@ class TestUndo:
def test_undo_re_signs_the_tombstone(self) -> None:
"""The mutated (tombstoned) entry still carries a valid signature."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
undo_last_today()
raw = _raw()
day = next(iter(raw))
@ -86,7 +86,7 @@ class TestUndo:
def test_undo_unsigned_when_no_key(self) -> None:
"""Re-signing a tombstone with no key available leaves it unsigned."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
undo_last_today()
raw = _raw()
@ -95,8 +95,8 @@ class TestUndo:
def test_undo_skips_already_tombstoned(self) -> None:
"""Undoing twice tombstones the prior entry, not the same one again."""
log_meal("a", _nut(100), slot=8)
log_meal("b", _nut(200), slot=12)
log_meal("a", _nut(100), slot=480)
log_meal("b", _nut(200), slot=720)
undo_last_today()
second = undo_last_today()
assert second is not None
@ -104,7 +104,7 @@ class TestUndo:
def test_undo_nothing_left_once_all_tombstoned(self) -> None:
"""Once every entry today is tombstoned, undo returns None."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
undo_last_today()
assert undo_last_today() is None
@ -114,7 +114,7 @@ class TestLoadLogSkipsTombstones:
def test_day_with_only_a_tombstone_is_omitted(self) -> None:
"""A day whose sole entry is tombstoned is dropped entirely."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
undo_last_today()
assert load_log() == {}
@ -124,7 +124,7 @@ class TestRawLogAccess:
def test_read_raw_log_includes_tombstones(self) -> None:
"""Unlike load_log, read_raw_log keeps a tombstoned entry."""
log_meal("a", _nut(100), slot=8)
log_meal("a", _nut(100), slot=480)
undo_last_today()
raw = read_raw_log()
day = next(iter(raw))
@ -163,7 +163,7 @@ class TestResignEntry:
def test_strips_and_recomputes_signature(self) -> None:
"""A re-signed entry's hmac changes but verifies against the key."""
entry = log_meal("a", _nut(100), slot=8)
entry = log_meal("a", _nut(100), slot=480)
tampered = dict(entry, kcal=999.0)
resigned = resign_entry(tampered)
assert resigned["hmac"] != entry["hmac"]
@ -171,9 +171,38 @@ class TestResignEntry:
with patch.object(_state_today, "_today", return_value="2026-06-22"):
assert today_entries() == [resigned]
def test_an_off_hour_slot_survives_a_resign_and_still_verifies(self) -> None:
"""``slot_min`` is covered by the new signature, not dropped by it.
The PC re-signs every merged entry; losing ``slot_min`` there would
quietly turn a phone-logged 07:15 meal back into a 07:00 one.
"""
entry = log_meal("a", _nut(100), slot=435)
assert (entry["slot"], entry["slot_min"]) == (7, 435)
resigned = resign_entry(entry)
assert resigned["slot_min"] == 435
write_raw_log({"2026-06-22": [resigned]})
with patch.object(_state_today, "_today", return_value="2026-06-22"):
assert today_entries() == [resigned]
def test_a_corrupt_peer_slot_is_relayed_not_raised_on(self) -> None:
"""Out-of-range peer values pass through re-signing and reading.
Only our own writers encode through ``slot_fields`` (which refuses an
out-of-range minute); a peer's ``slot: 24`` must ride through the
merge path unchanged -- raising here would stall every sync tick.
"""
peer = {"id": "p", "time": "2026-06-22T09:00:00+02:00", "kcal": 1.0}
resigned = resign_entry({**peer, "slot": 24, "slot_min": 5000})
assert (resigned["slot"], resigned["slot_min"]) == (24, 5000)
write_raw_log({"2026-06-22": [resigned]})
with patch.object(_state_today, "_today", return_value="2026-06-22"):
# 5000 snaps to the last slot rather than raising.
assert logged_slots_today() == {1200}
def test_no_op_signature_wise_when_no_key_available(self) -> None:
"""Without an HMAC key, resign_entry produces no hmac field."""
entry = log_meal("a", _nut(100), slot=8)
entry = log_meal("a", _nut(100), slot=480)
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
resigned = resign_entry(entry)
assert "hmac" not in resigned

View File

@ -14,6 +14,8 @@ import pytest
from diet_guard import _state
from diet_guard._budget import BudgetNotInitializedError, write_budget
from diet_guard._estimator import Nutrition
from diet_guard._meal_schedule import MealSchedule
from diet_guard._meal_schedule_store import record_schedule_change
from diet_guard._state import (
log_meal,
)
@ -40,19 +42,36 @@ class TestLoggedSlots:
"""Which slots today's log has satisfied."""
def test_int_slots_counted(self) -> None:
"""Integer slot tags are reported."""
log_meal("a", _nut(1), slot=8)
log_meal("b", _nut(1), slot=12)
assert logged_slots_today() == {8, 12}
"""Whole-hour slot minutes are reported as the slots they are."""
log_meal("a", _nut(1), slot=480)
log_meal("b", _nut(1), slot=720)
assert logged_slots_today() == {480, 720}
def test_bool_slot_excluded(self) -> None:
"""A bool masquerading as a slot is ignored."""
log_meal("a", _nut(1), slot=8)
log_meal("a", _nut(1), slot=480)
raw = _raw()
day = next(iter(raw))
raw[day].append({"kcal": 1, "slot": True})
_state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8")
assert logged_slots_today() == {8}
assert logged_slots_today() == {480}
def test_an_off_hour_slot_is_written_and_read_as_minutes(self) -> None:
"""07:15 survives the round trip through disk and the HMAC check."""
record_schedule_change(MealSchedule(435, 1140, 5))
log_meal("a", _nut(1), slot=435)
assert logged_slots_today() == {435}
def test_a_legacy_hour_entry_counts_toward_the_nearest_slot(self) -> None:
"""An old ``{"slot": 7}`` entry still covers a 07:15 checkpoint."""
record_schedule_change(MealSchedule(435, 1140, 5))
log_meal("a", _nut(1), slot=420)
assert logged_slots_today() == {435}
def test_an_explicit_schedule_is_snapped_onto(self) -> None:
"""The caller's schedule wins over the stored one."""
log_meal("a", _nut(1), slot=480)
assert logged_slots_today(MealSchedule(495, 1200, 4)) == {495}
class TestBudgetViews:
@ -66,23 +85,23 @@ class TestBudgetViews:
def test_remaining_value(self) -> None:
"""Remaining is budget minus today's total."""
write_budget(2000)
log_meal("lunch", _nut(500), slot=12)
log_meal("lunch", _nut(500), slot=720)
assert remaining_budget() == 1500.0
def test_band_on_track(self) -> None:
"""Well under the warn fraction is 'on track'."""
write_budget(2000)
log_meal("a", _nut(500), slot=8)
log_meal("a", _nut(500), slot=480)
assert consumption_band() == "on track"
def test_band_approaching(self) -> None:
"""At or above the warn fraction but under budget is 'approaching limit'."""
write_budget(2000)
log_meal("a", _nut(1700), slot=8)
log_meal("a", _nut(1700), slot=480)
assert consumption_band() == "approaching limit"
def test_band_over(self) -> None:
"""At or above budget is 'OVER BUDGET'."""
write_budget(2000)
log_meal("a", _nut(2100), slot=8)
log_meal("a", _nut(2100), slot=480)
assert consumption_band() == "OVER BUDGET"

View File

@ -45,7 +45,7 @@ class TestRunSync:
def test_pushes_local_log_when_no_other_devices_have_synced(self) -> None:
_write_token()
log_meal("oatmeal", _nutrition(), slot=8)
log_meal("oatmeal", _nutrition(), slot=480)
client = _mock_client(devices=())
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
@ -192,7 +192,7 @@ class TestRunSync:
def test_rebuilds_the_food_bank_after_merge(self) -> None:
_write_token()
log_meal("oatmeal", _nutrition(), slot=8)
log_meal("oatmeal", _nutrition(), slot=480)
client = _mock_client(devices=())
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()

View File

@ -145,11 +145,11 @@ class TestSyncBudget:
"""
_write_token()
write_budget(2000)
record_schedule_change(MealSchedule(8, 20, 5))
record_schedule_change(MealSchedule(480, 1200, 5))
client = _mock_client(devices=())
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert load_schedule_entries()[-1].schedule == MealSchedule(8, 20, 5)
assert load_schedule_entries()[-1].schedule == MealSchedule(480, 1200, 5)
def test_malformed_remote_budget_is_skipped(self) -> None:
"""A corrupt remote budget.json is skipped, not a crash."""

View File

@ -166,6 +166,20 @@ class TestEntryRecordRoundTrip:
entry = _entry(id="x")
assert record_to_entry(entry_to_record(entry)) == entry
def test_an_off_hour_slot_survives_a_merge(self) -> None:
"""``slot_min`` rides in the opaque body, so a merge keeps it.
Merged from both sides so neither argument order can drop it -- a
lost ``slot_min`` would silently move a 07:15 meal to 07:00.
"""
entry = _entry(id="x", slot=7, slot_min=435)
for merged in (
_merge_daylogs({"2026-06-22": [entry]}, {}),
_merge_daylogs({}, {"2026-06-22": [entry]}),
):
assert merged["2026-06-22"][0]["slot_min"] == 435
assert merged["2026-06-22"][0]["slot"] == 7
def test_round_trip_of_a_deleted_entry_preserves_the_tombstone(self) -> None:
entry = _entry(id="x", deleted=True)
assert record_to_entry(entry_to_record(entry))["deleted"] is True

View File

@ -42,7 +42,13 @@ class TestScheduleFields:
def test_one_field_per_entry(self) -> None:
"""Each entry becomes its own ``sched:<date>`` field."""
fields = schedule_fields(
(_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),)
(
_entry(
"2026-08-16",
MealSchedule(480, 1200, 5),
"2026-08-16T10:00:00+02:00",
),
)
)
assert set(fields) == {f"{SCHEDULE_FIELD_PREFIX}2026-08-16"}
value, _ = fields[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"]
@ -51,7 +57,7 @@ class TestScheduleFields:
def test_hlc_is_deterministic(self) -> None:
"""Re-syncing an unchanged history is a no-op, not a fresh write."""
entry = _entry(
"2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"
"2026-08-16", MealSchedule(480, 1200, 5), "2026-08-16T10:00:00+02:00"
)
first = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
second = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
@ -59,7 +65,7 @@ class TestScheduleFields:
def test_unparsable_timestamp_falls_back_to_the_epoch(self) -> None:
"""A malformed stamp still yields a clock, so the entry still merges."""
entry = _entry("2026-08-16", MealSchedule(8, 20, 5), "not-a-timestamp")
entry = _entry("2026-08-16", MealSchedule(480, 1200, 5), "not-a-timestamp")
hlc = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
assert hlc.wall_time_ms == 0
@ -74,12 +80,30 @@ class TestLogToScheduleHistory:
def test_round_trips(self) -> None:
"""What one device pushes is what the other reads back."""
entries = (
_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),
_entry(
"2026-08-16", MealSchedule(480, 1200, 5), "2026-08-16T10:00:00+02:00"
),
)
back = log_to_schedule_history(budget_to_log(_RECORD, (), entries))
assert len(back) == 1
assert back[0].effective_from == "2026-08-16"
assert back[0].schedule == MealSchedule(8, 20, 5)
assert back[0].schedule == MealSchedule(480, 1200, 5)
def test_round_trips_an_off_hour_schedule(self) -> None:
"""07:15-19:00 crosses the wire with its minutes, ``f`` still an hour.
An older peer reads ``f``/``l`` and relays ``fm`` untouched; a peer on
this build reads ``fm`` and derives the same slots as the editor.
"""
entries = (
_entry(
"2026-08-16", MealSchedule(435, 1140, 5), "2026-08-16T10:00:00+02:00"
),
)
value, _ = schedule_fields(entries)[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"]
assert value == {"f": 7, "l": 19, "n": 5, "fm": 435}
back = log_to_schedule_history(budget_to_log(_RECORD, (), entries))
assert back[0].schedule == MealSchedule(435, 1140, 5)
def test_normalizes_a_peers_out_of_range_schedule(self) -> None:
"""A future peer cannot make this device derive different slots."""
@ -90,7 +114,7 @@ class TestLogToScheduleHistory:
{"f": 8, "l": 20, "n": 99},
hlc,
)
assert log_to_schedule_history(log)[0].schedule == MealSchedule(8, 20, 6)
assert log_to_schedule_history(log)[0].schedule == MealSchedule(480, 1200, 6)
def test_skips_malformed_values(self) -> None:
"""One bad field from a peer cannot take out the whole history."""
@ -118,7 +142,9 @@ class TestCrossDeviceMerge:
"""
entries = (
_entry("1970-01-01", DEFAULT_SCHEDULE, "1970-01-01T00:00:00+00:00"),
_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),
_entry(
"2026-08-16", MealSchedule(480, 1200, 5), "2026-08-16T10:00:00+02:00"
),
)
ours = budget_to_log(_RECORD, (), entries)
peer = budget_to_log(
@ -130,7 +156,7 @@ class TestCrossDeviceMerge:
assert [entry.schedule for entry in back] == [
DEFAULT_SCHEDULE,
MealSchedule(8, 20, 5),
MealSchedule(480, 1200, 5),
]
def test_the_newer_edit_wins_per_day(self) -> None:
@ -140,7 +166,9 @@ class TestCrossDeviceMerge:
(),
(
_entry(
"2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"
"2026-08-16",
MealSchedule(480, 1200, 5),
"2026-08-16T10:00:00+02:00",
),
),
)
@ -149,10 +177,12 @@ class TestCrossDeviceMerge:
(),
(
_entry(
"2026-08-16", MealSchedule(7, 21, 3), "2026-08-16T18:00:00+02:00"
"2026-08-16",
MealSchedule(420, 1260, 3),
"2026-08-16T18:00:00+02:00",
),
),
)
merged = merge_logs(parse_remote_budget(_wire(older)), newer)
assert log_to_schedule_history(merged)[0].schedule == MealSchedule(7, 21, 3)
assert log_to_schedule_history(merged)[0].schedule == MealSchedule(420, 1260, 3)

View File

@ -2,38 +2,77 @@
_Split out of `CLAUDE.md` to keep it under the repo's 250-line cap._
The user sets a first meal hour, a last meal hour, and how many meals fall
between them; the intermediate checkpoints are derived by even division.
`MealSchedule(8, 20, 5)` → `08:00, 11:00, 14:00, 17:00, 20:00`. The default
`MealSchedule(8, 20, 4)` reproduces the hours that used to be hardcoded, so an
existing install sees no change until it is edited.
Every slot is a **minute of day**, an int in `0..1439`. The user sets a first
meal and a last meal (any `HH:MM`) and how many meals fall between them; the
intermediate checkpoints are derived by even division and rounded to the
15-minute grid. `MealSchedule(480, 1200, 5)` → `08:00, 11:00, 14:00, 17:00,
20:00`; `MealSchedule(435, 1140, 5)` → `07:15, 10:15, 13:15, 16:00, 19:00`.
The default `MealSchedule(480, 1200, 4)` reproduces the hours that used to be
hardcoded, so an existing install sees no change until it is edited.
Four rules, each load-bearing:
The fields are `first_minute`/`last_minute` (`firstMinute`/`lastMinute`),
deliberately renamed from the old hour-valued `first`/`last` so the type
checker enumerated every caller still thinking in hours.
- **Integer arithmetic only** — see the `Do NOT` entry below. The shared
test-vector table plus an identical 0..23 × 0..23 × 2..6 sweep in
`test_meal_schedule.py` and `meal_schedule_test.dart` is what catches a
cross-language divergence before it reaches a device.
- **Slots stay whole hours.** Changing that changes the on-disk log's `slot`
tag and the sync wire format, on both devices.
- **Counts are clamped to the window width** (`last - first + 1`), because a
narrow window would otherwise round two meals onto the same hour, and slot
hours are set members, dict keys *and* notification ids — a duplicate
silently drops a checkpoint. The two edit surfaces reject such input with a
message; `normalized()` clamps it, which is the defence against a peer's
corrupt sync data rather than the input contract.
- **The enforcement cutoff is `last + 2h`, clamped to midnight.** It is a
## The rules, each load-bearing
- **Integer arithmetic only** — see the `Do NOT` entry in `CLAUDE.md`.
Python's `round` is banker's and Dart's is half-away-from-zero, and
Python's `//` floors where Dart's `~/` truncates, so every division runs on
a value `normalized()` has already pulled into the day. The shared vectors
in `tests/fixtures/meal_schedule_vectors.json` (regenerated by
`scripts/build_meal_schedule_fixture.py`) are asserted by
`test_meal_schedule_vectors.py` *and* `meal_schedule_vectors_test.dart`;
that is what catches a cross-language divergence before it reaches a device.
- **Interior slots round to the 15-minute grid; the endpoints never do.**
With `span = last - first` and `d = count - 1`, interior slot *i* is
`raw = first + (i*span + d//2) // d`, snapped by `(raw + 7) // 15 * 15`.
The first and last meal are exactly what the user typed, even `07:23`.
- **Counts are clamped to the window, in 15-minute steps:**
`count <= min(6, (last - first) // 15 + 1)`, and `last >= first + 15`.
Two checkpoints closer than one grid step would snap onto the same mark,
and slot minutes are set members, dict keys *and* notification ids — a
duplicate silently drops a checkpoint. The two edit surfaces reject such
input with a message; `normalized()` clamps it, which is the defence
against a peer's corrupt sync data rather than the input contract.
- **The enforcement cutoff is `last + 120` minutes, clamped to 1440.** It is a
grace period for a late dinner, deliberately independent of meal count.
Unclamped, a 23:00 last meal gives 25 and `hour < cutoff` is vacuously
true — the window never closes.
Unclamped, a 23:00 last meal gives 1500 and `minute < cutoff` is vacuously
true — the window never closes and the gate can never stop firing.
- **The wire is additive; whole hours are byte-identical to the old format.**
- A log entry always carries `slot = minute // 60` and adds
`slot_min = minute` only when `minute % 60 != 0` (`_slot_wire.slot_fields`
/ `slotFields`). Readers prefer an int `slot_min`, else `slot * 60`
(`entry_slot_minute` / `entrySlotMinute`). A whole-hour entry is exactly
what an old build wrote — no re-signing storm — and an old build reading an
off-hour entry sees the hour it starts in. The writer adds `slot_min`
**before** signing: the HMAC covers the whole entry.
- A schedule history entry (`sched:<date>` on the `budget` record, and the
`.meal_schedule` file) carries `f`/`l` as hours and `n`, plus `fm`/`lm`
minutes only for an off-hour endpoint (`schedule_to_wire` /
`scheduleToWire`). `fm`/`lm` win when present.
- `bool` is never an int, on either side: Python's `bool` subclasses `int`,
Dart's does not, and a value one device accepts while the other ignores
is a split brain.
- **A logged meal satisfies the slot nearest its recorded minute**
(`satisfied_slots` / `satisfiedSlots`; an exact tie goes to the *earlier*
slot). Nothing on disk is rewritten when the schedule changes: meals logged
at 07:00 and 10:00 under `07:00–19:00 × 5` still cover 07:15 and 10:15
after a move to `07:15–19:00 × 5`, so nothing re-opens. Every "is this slot
logged?" site goes through it — the gate (`logged_slots_today`), the CLI
status, MCP, and the catering import's occupancy and dedup
(`_slot_wire.satisfied_by_entries`). Many-to-one is intended: two meals
that snap onto one slot satisfy only that slot.
`_slots.py` / `slot.dart` take the schedule as a **required** argument. A
default there would let a call site missed in a refactor keep deriving the old
fixed hours on one device only, which is the split brain that can lock the
fixed slots on one device only, which is the split brain that can lock the
user out; making it required lets mypy and `flutter analyze` enumerate the
sites. Callers resolve it at the impure edge through
`_meal_schedule_store.current_schedule()` / `MealScheduleService.current`.
## History and editing
The history is forward-only (`_meal_schedule_store.py` /
`meal_schedule_history.dart`), so switching from four meals to five does not
retroactively mark past days as having missed a checkpoint. It seeds the
@ -44,8 +83,22 @@ them untouched.
Editable on both surfaces: the app's Settings screen
(`settings_meal_schedule.dart`) and the gate's History tab
(`_gatelock_scheduleedit.py`).
(`_gatelock_scheduleedit.py`, widgets in `_gatelock_calendar_schedule.py`).
The gate's first/last fields are `ttk.Spinbox`es: any typed `HH:MM`, and the
arrow buttons, Up/Down and the wheel step to the neighbouring quarter hour
from the typed value (07:23 → 07:30 / 07:15), clamped to 00:00–23:45 (the last
meal to 00:15–23:45). `<<Increment>>`/`<<Decrement>>` are bound and answered
with `"break"`, so ttk's own values/wrap logic never runs. **No popups on the
lock:** a `ttk.Combobox` was tried first, and its popdown mapped *beneath* the
lock surface (gatelock keeps it raised) while still holding ttk's grab —
invisible, it swallowed the next click and stole keystrokes.
**Notification ids are slot hours.** `syncToSlots` must sweep the whole 0..23
id space, not the current schedule's slots — otherwise a schedule change
orphans the ids it no longer contains and they nag forever.
`diet-guard-gate.timer` fires every 15 minutes (`*:00/15:00`), so it lands on
every grid slot exactly; an off-grid first/last meal is caught by the next
tick.
**Notification ids are slot minutes.** The app's `syncToSlots` must sweep the
whole id space — every minute id *and* the legacy `0..23` hour ids an older
build scheduled — not just the current schedule's slots. Otherwise a schedule
change (or the upgrade itself) orphans ids it no longer contains, and they
nag forever.

View File

@ -10,7 +10,7 @@
# site-packages (the systemd service runs /usr/bin/python directly, not
# a venv, so the package must live where that interpreter can find it —
# see CLAUDE.md's "Production dependency installation" section)
# 3. Installs + enables the systemd user timer that fires the gate every ~30m
# 3. Installs + enables the systemd user timer that fires the gate every ~15m
# 4. Installs + enables the systemd user timer that syncs the log every ~15m
# (the sync itself stays unconfigured -- and a no-op -- until you create
# a sync token; see the reminder this step prints)
@ -57,7 +57,7 @@ cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
systemctl --user daemon-reload
systemctl --user enable --now diet-guard-gate.timer
echo " Timer enabled and started (fires the gate every ~30 min)."
echo " Timer enabled and started (fires the gate every ~15 min)."
# 4. Retire the old periodic sync timer ---------------------------------------
# Sync is event-driven now (before a lock, after a meal is logged -- see
@ -96,6 +96,6 @@ else
fi
echo "=== Installation complete ==="
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
echo "once you have gone 5h without logging."
echo "The gate checks every ~15 min and locks until you log a meal once a meal"
echo "slot passes unlogged (08:00/12:00/16:00/20:00 by default; editable)."
echo "Test the lock now (safe, closeable): python -m diet_guard gate --demo"