mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Merge branch 'worktree-agent-a92471b85d4d17556' into minute-slots
This commit is contained in:
commit
a118120047
19
AGENTS.md
19
AGENTS.md
@ -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`)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
#
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)],
|
||||
)
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
65
diet_guard/tests/test_gate_schedule_move.py
Normal file
65
diet_guard/tests/test_gate_schedule_move.py
Normal 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,)
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
99
diet_guard/tests/test_gatelock_calendar_schedule.py
Normal file
99
diet_guard/tests/test_gatelock_calendar_schedule.py
Normal 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"
|
||||
@ -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=()),
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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([]) == []
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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"}
|
||||
|
||||
65
diet_guard/tests/test_mcp_slot.py
Normal file
65
diet_guard/tests/test_mcp_slot.py
Normal 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
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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() == {}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user