mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +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
|
# 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
|
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
|
user-configurable, see below) elapsed unlogged; if so it opens a
|
||||||
fullscreen Tk window that blocks the desktop until the user logs what they
|
fullscreen Tk window that blocks the desktop until the user logs what they
|
||||||
@ -29,7 +29,7 @@ catch-up rule).
|
|||||||
|
|
||||||
## Scheduling
|
## 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
|
`Persistent=true`), not boot-relative — a boot-relative timer collided with
|
||||||
fullscreen games grabbing input. `diet-guard-gate.service` is `Type=oneshot`
|
fullscreen games grabbing input. `diet-guard-gate.service` is `Type=oneshot`
|
||||||
and exits 0 immediately when no lock is due. It needs `DISPLAY`/`XAUTHORITY`;
|
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 meal schedule
|
||||||
|
|
||||||
The slot hours are user-configurable: a first meal, a last meal, and how
|
Slots are **minutes of day**: a first meal and a last meal (any `HH:MM`) and
|
||||||
many meals fall between them, evenly divided. Default `(8, 20, 4)` is the
|
how many meals fall between them; interior slots round to the 15-min grid.
|
||||||
old hardcoded 08/12/16/20. Four load-bearing rules (integer-only
|
Default `(480, 1200, 4)` is the old hardcoded 08/12/16/20. The load-bearing
|
||||||
arithmetic, whole-hour slots, the count clamp, the `last + 2h` cutoff), the
|
rules (integer-only arithmetic, the additive `slot`+`slot_min` /
|
||||||
forward-only history, and the two edit surfaces are documented in
|
`f`/`l`/`n`+`fm`/`lm` wire, the count clamp, the `last + 120 min` cutoff,
|
||||||
[docs/DOCS-meal-schedule.md](docs/DOCS-meal-schedule.md) — read it before touching
|
nearest-slot satisfaction), the forward-only history, and the two edit
|
||||||
`_slots.py` / `slot.dart`.
|
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`)
|
## 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
|
over / more than 20% over), so a period and its day cells can never disagree
|
||||||
about what "over" means.
|
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.
|
invocation is needed once installed.
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|||||||
@ -7,7 +7,7 @@ Type=oneshot
|
|||||||
# DISPLAY mirrors wake-alarm.service: the gate opens a Tk window when a lock
|
# 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
|
# 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()
|
# $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),
|
# No PYTHONPATH needed: diet_guard is pip-installed (see install.sh / README),
|
||||||
# so /usr/bin/python finds it via user site-packages.
|
# so /usr/bin/python finds it via user site-packages.
|
||||||
#
|
#
|
||||||
|
|||||||
@ -2,10 +2,13 @@
|
|||||||
Description=Periodically run the Diet Guard gate check
|
Description=Periodically run the Diet Guard gate check
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
# Check on a WALL-CLOCK schedule (every 30 min, on the hour and half-hour).
|
# Check on a WALL-CLOCK schedule (every 15 min: :00, :15, :30, :45).
|
||||||
# The 5-hour "log a meal" threshold and the 08:00-22:00 eating window are
|
# The meal slots and the enforcement window are enforced inside gate_is_due(),
|
||||||
# enforced inside gate_is_due(), so the timer only needs to fire often enough to
|
# so the timer only needs to fire often enough to notice a slot opening. Slots
|
||||||
# notice the boundary; 30 min is plenty.
|
# 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*
|
# Why OnCalendar and not OnBootSec/OnUnitActiveSec: the gate is a *blocking*
|
||||||
# oneshot -- it stays up until a meal is logged -- and a monotonic schedule
|
# 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
|
# reboot), leaving the timer dormant (NextElapse=infinity). OnCalendar re-arms
|
||||||
# every period regardless of how long the gate stayed open, and Persistent
|
# every period regardless of how long the gate stayed open, and Persistent
|
||||||
# catches a run missed while the machine was suspended.
|
# catches a run missed while the machine was suspended.
|
||||||
OnCalendar=*-*-* *:00/30:00
|
OnCalendar=*-*-* *:00/15:00
|
||||||
Persistent=true
|
Persistent=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@ -116,10 +116,11 @@ def _print_entry_line(entry: dict[str, object]) -> None:
|
|||||||
|
|
||||||
def _print_slot_status() -> None:
|
def _print_slot_status() -> None:
|
||||||
"""Print each meal slot as logged / DUE / upcoming for today."""
|
"""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())
|
due = set(due_slots())
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for slot in day_slots(current_schedule()):
|
for slot in day_slots(schedule):
|
||||||
if slot in logged:
|
if slot in logged:
|
||||||
mark = "logged"
|
mark = "logged"
|
||||||
elif slot in due:
|
elif slot in due:
|
||||||
|
|||||||
@ -87,9 +87,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
)
|
)
|
||||||
ate.add_argument(
|
ate.add_argument(
|
||||||
"--hour",
|
"--hour",
|
||||||
type=int,
|
type=str,
|
||||||
default=None,
|
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.")
|
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._kuchnia_spread import assign_slots
|
||||||
from diet_guard._meal_schedule_store import current_schedule
|
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
|
from diet_guard._state import now_local
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
@ -99,7 +99,7 @@ def cmd_kuchnia(
|
|||||||
emit(f"{len(dishes)} dish(es) delivered today, banked:")
|
emit(f"{len(dishes)} dish(es) delivered today, banked:")
|
||||||
for item in slotted:
|
for item in slotted:
|
||||||
emit(
|
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)",
|
f"({item.dish.kcal:g} kcal, {item.dish.grams:g} g)",
|
||||||
)
|
)
|
||||||
total = sum(dish.kcal for dish in dishes)
|
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._meal_schedule_store import current_schedule
|
||||||
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
||||||
from diet_guard._resolve import ManualMacros, resolve_nutrition
|
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._state import log_meal, now_local
|
||||||
from diet_guard._sync_events import publish_after_log_detached
|
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.
|
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.
|
date: Log against this future date (``YYYY-MM-DD``) instead of now.
|
||||||
Must be given together with ``hour``. None means "now".
|
Must be given together with ``hour``. None means "now".
|
||||||
hour: The slot hour on ``date`` this meal satisfies. Required
|
hour: The slot on ``date`` this meal satisfies, as the user typed it
|
||||||
together with ``date``.
|
(``HH:MM`` or ``HH``; parsed by
|
||||||
|
:func:`diet_guard._slot_wire.parse_hhmm`). Required together with
|
||||||
|
``date``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
grams: float | None
|
grams: float | None
|
||||||
count: float | None
|
count: float | None
|
||||||
per_grams: float | None
|
per_grams: float | None
|
||||||
date: str | None = None
|
date: str | None = None
|
||||||
hour: int | None = None
|
hour: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def eaten_grams(
|
def eaten_grams(
|
||||||
@ -116,13 +119,15 @@ def _resolve_target(
|
|||||||
limit.
|
limit.
|
||||||
|
|
||||||
Raises:
|
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`.
|
combination -- see :func:`diet_guard._slots.resolve_future_when`.
|
||||||
"""
|
"""
|
||||||
if portion.date is None or portion.hour is None:
|
if portion.date is None or portion.hour is None:
|
||||||
return None, slot_for_log(now_local(), schedule)
|
return None, slot_for_log(now_local(), schedule)
|
||||||
when = resolve_future_when(portion.date, portion.hour, schedule, now_local())
|
slot = parse_hhmm(portion.hour)
|
||||||
return when, slot_for_log(when, schedule)
|
when = resolve_future_when(portion.date, slot, schedule, now_local())
|
||||||
|
return when, slot
|
||||||
|
|
||||||
|
|
||||||
def cmd_ate(
|
def cmd_ate(
|
||||||
@ -195,7 +200,7 @@ def cmd_ate(
|
|||||||
emit(
|
emit(
|
||||||
f"logged: {description} {nutrition.kcal:g} kcal "
|
f"logged: {description} {nutrition.kcal:g} kcal "
|
||||||
f"({macro_str}) [{nutrition.source}, {portion_str}]"
|
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
|
# 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.
|
# 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
|
DEFAULT_PORTION_GRAMS: float = 100.0
|
||||||
|
|
||||||
# --- Gate (log-to-unlock) ---------------------------------------------------
|
# --- Gate (log-to-unlock) ---------------------------------------------------
|
||||||
# The gate is driven by FIXED MEAL SLOTS, not by a gap timer. Starting at the
|
# The gate is driven by MEAL SLOTS, not by a gap timer: once a slot's minute has
|
||||||
# day-start hour, a slot opens every interval; once a slot's hour has passed,
|
# passed, that slot must carry a logged meal or the screen locks until it does.
|
||||||
# that slot must carry a logged meal or the screen locks until it does. This
|
# Coming home late naturally produces several unlogged elapsed slots at once ->
|
||||||
# makes tracking fully automatic (you are prompted on a schedule rather than
|
# one lock that backfills the whole day. The slot minutes and the overnight
|
||||||
# trusted to log voluntarily) and nudges regular eating. Coming home late
|
# cutoff come from the user's schedule (``_meal_schedule``), not constants here.
|
||||||
# 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)
|
|
||||||
# flock single-instance guard: stops a timer from stacking lock windows.
|
# flock single-instance guard: stops a timer from stacking lock windows.
|
||||||
GATE_LOCK_FILE: Path = DATA_DIR / ".gate.lock"
|
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.
|
now: Reference time (defaults to the current local time); injectable.
|
||||||
|
|
||||||
Returns:
|
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
|
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
|
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()
|
reference = now if now is not None else now_local()
|
||||||
if freedays.is_free_day(reference.date()):
|
if freedays.is_free_day(reference.date()):
|
||||||
return ()
|
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:
|
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.
|
demo_mode: Whether the window is a safe sandbox.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The slot hours to collect, ascending.
|
The slot minutes to collect, ascending.
|
||||||
"""
|
"""
|
||||||
pending = list(due_slots())
|
pending = list(due_slots())
|
||||||
if pending:
|
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
|
250-line cap) rather than added to it. The behaviour behind these widgets
|
||||||
lives in :mod:`._gatelock_scheduleedit`, mirroring how the budget row's
|
lives in :mod:`._gatelock_scheduleedit`, mirroring how the budget row's
|
||||||
widgets and its edit/save logic are split.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from gatelock import make_button
|
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_spacing import SM, XS
|
||||||
from diet_guard._gatelock_typography import BODY, LABEL
|
from diet_guard._gatelock_typography import BODY, LABEL
|
||||||
from diet_guard._gatelock_ui import BG, FG
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from diet_guard._gatelock_calendar_types import CalendarVars
|
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
|
_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:
|
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(
|
entry = tk.Entry(
|
||||||
row,
|
row,
|
||||||
textvariable=variable,
|
textvariable=variable,
|
||||||
@ -46,45 +141,38 @@ def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry:
|
|||||||
return 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(
|
def build_schedule_row(
|
||||||
parent: tk.Frame,
|
parent: tk.Frame,
|
||||||
vars_: CalendarVars,
|
vars_: CalendarVars,
|
||||||
on_edit_or_save_schedule: Callable[[], None],
|
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.
|
"""Build the meal-schedule row.
|
||||||
|
|
||||||
Returns the three hour/count entries, the edit button, and the status
|
Returns the two time spinboxes, the count entry, the edit button, and the
|
||||||
label, in that order. Like the budget row the entries start read-only:
|
status label, in that order. Like the budget row the fields start locked:
|
||||||
the schedule is displayed but not directly typeable until "Edit".
|
the schedule is displayed but not directly editable until "Edit".
|
||||||
|
|
||||||
Returns:
|
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 = tk.Frame(parent, bg=BG)
|
||||||
row.pack(pady=(SM, XS))
|
row.pack(pady=(SM, XS))
|
||||||
tk.Label(
|
_caption(row, "Meals:")
|
||||||
row,
|
first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE)
|
||||||
text="Meals:",
|
_caption(row, "to")
|
||||||
font=(_COLORS.typography.font_family, LABEL),
|
last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE)
|
||||||
bg=BG,
|
_caption(row, "x")
|
||||||
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")
|
|
||||||
count_entry = _spin_entry(row, vars_.schedule.count)
|
count_entry = _spin_entry(row, vars_.schedule.count)
|
||||||
edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule)
|
edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule)
|
||||||
edit_button.pack(side="left")
|
edit_button.pack(side="left")
|
||||||
@ -106,4 +194,4 @@ def build_schedule_row(
|
|||||||
fg=FG,
|
fg=FG,
|
||||||
)
|
)
|
||||||
status_label.pack(pady=(0, XS))
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from tkinter import ttk
|
||||||
|
|
||||||
# BG/FG/ERR come from _gatelock_ui's public exports (themselves sourced from
|
# 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
|
# LockConfig -- see DESIGN_AUDIT_TODO.md). The rest of the calendar's palette
|
||||||
@ -60,7 +61,9 @@ class ScheduleVars:
|
|||||||
|
|
||||||
status: tk.StringVar
|
status: tk.StringVar
|
||||||
first: tk.StringVar
|
first: tk.StringVar
|
||||||
|
"""The first meal as ``HH:MM`` text, parsed only on save."""
|
||||||
last: tk.StringVar
|
last: tk.StringVar
|
||||||
|
"""The last meal as ``HH:MM`` text, parsed only on save."""
|
||||||
count: tk.StringVar
|
count: tk.StringVar
|
||||||
times: tk.StringVar
|
times: tk.StringVar
|
||||||
"""The derived checkpoint times, e.g. ``"08:00 11:00 14:00"``."""
|
"""The derived checkpoint times, e.g. ``"08:00 11:00 14:00"``."""
|
||||||
@ -89,8 +92,8 @@ class CalendarWidgets:
|
|||||||
budget_entry: tk.Entry
|
budget_entry: tk.Entry
|
||||||
budget_edit_button: tk.Button
|
budget_edit_button: tk.Button
|
||||||
budget_status_label: tk.Label
|
budget_status_label: tk.Label
|
||||||
schedule_first_entry: tk.Entry
|
schedule_first_entry: ttk.Spinbox
|
||||||
schedule_last_entry: tk.Entry
|
schedule_last_entry: ttk.Spinbox
|
||||||
schedule_count_entry: tk.Entry
|
schedule_count_entry: tk.Entry
|
||||||
schedule_edit_button: tk.Button
|
schedule_edit_button: tk.Button
|
||||||
schedule_status_label: tk.Label
|
schedule_status_label: tk.Label
|
||||||
|
|||||||
@ -52,7 +52,10 @@ __all__ = [
|
|||||||
|
|
||||||
|
|
||||||
def _style_notebook(root: tk.Misc) -> None:
|
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
|
``ttk`` widgets ignore plain ``bg=``/``fg=`` -- unlike every other widget
|
||||||
here, they only take color from a named ``ttk.Style``. Without this the
|
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)],
|
background=[("selected", _ACCENT)],
|
||||||
foreground=[("selected", _COLORS.palette.on_fill)],
|
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_log import fill_plan, log_dishes
|
||||||
from diet_guard._kuchnia_spread import assign_slots
|
from diet_guard._kuchnia_spread import assign_slots
|
||||||
from diet_guard._meal_schedule_store import current_schedule
|
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
|
from diet_guard._state import now_local
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
@ -144,7 +144,9 @@ class FillAllFlow:
|
|||||||
self._plan = plan
|
self._plan = plan
|
||||||
self._label.set(f"✓ Confirm ({len(plan)})")
|
self._label.set(f"✓ Confirm ({len(plan)})")
|
||||||
total = sum(item.dish.kcal for item in 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(
|
self._set_status(
|
||||||
f"Will log {len(plan)} ({total:g} kcal): {listing} — click Confirm."
|
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
|
: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
|
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.
|
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
|
from __future__ import annotations
|
||||||
@ -22,16 +26,20 @@ import tkinter as tk
|
|||||||
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
|
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
|
||||||
from diet_guard._gatelock_ui import ERR, FG
|
from diet_guard._gatelock_ui import ERR, FG
|
||||||
from diet_guard._meal_schedule import (
|
from diet_guard._meal_schedule import (
|
||||||
FIRST_HOUR,
|
|
||||||
LAST_HOUR,
|
|
||||||
MAX_MEAL_COUNT,
|
MAX_MEAL_COUNT,
|
||||||
MIN_MEAL_COUNT,
|
MIN_MEAL_COUNT,
|
||||||
|
SLOT_GRID_MINUTES,
|
||||||
MealSchedule,
|
MealSchedule,
|
||||||
)
|
)
|
||||||
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
|
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
|
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:
|
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))
|
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):
|
class _GateScheduleEdit(_GateBudgetEdit):
|
||||||
"""The History tab's meal-schedule row: display, edit, validate, persist.
|
"""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``."""
|
"""Repaint the History tab; implemented by ``_GateCalendar``."""
|
||||||
|
|
||||||
def _set_schedule_entry_state(self, state: str) -> None:
|
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 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):
|
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)
|
surface.schedule_count_entry.config(state=state)
|
||||||
|
|
||||||
def _set_schedule_button_text(self, text: str) -> None:
|
def _set_schedule_button_text(self, text: str) -> None:
|
||||||
@ -78,8 +112,8 @@ class _GateScheduleEdit(_GateBudgetEdit):
|
|||||||
|
|
||||||
def _show_schedule(self, schedule: MealSchedule) -> None:
|
def _show_schedule(self, schedule: MealSchedule) -> None:
|
||||||
"""Populate the row's fields and derived-times label."""
|
"""Populate the row's fields and derived-times label."""
|
||||||
self._cal_vars.schedule.first.set(str(schedule.first))
|
self._cal_vars.schedule.first.set(slot_label(schedule.first_minute))
|
||||||
self._cal_vars.schedule.last.set(str(schedule.last))
|
self._cal_vars.schedule.last.set(slot_label(schedule.last_minute))
|
||||||
self._cal_vars.schedule.count.set(str(schedule.count))
|
self._cal_vars.schedule.count.set(str(schedule.count))
|
||||||
self._cal_vars.schedule.times.set(schedule_summary(schedule))
|
self._cal_vars.schedule.times.set(schedule_summary(schedule))
|
||||||
|
|
||||||
@ -104,16 +138,19 @@ class _GateScheduleEdit(_GateBudgetEdit):
|
|||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
|
|
||||||
def _read_schedule_fields(self) -> MealSchedule | None:
|
def _read_schedule_fields(self) -> MealSchedule | None:
|
||||||
"""Parse the three entries, or None (with a status set) if unusable."""
|
"""Parse the three fields, 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(),
|
|
||||||
)
|
|
||||||
try:
|
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:
|
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 None
|
||||||
return MealSchedule(first, last, count)
|
return MealSchedule(first, last, count)
|
||||||
|
|
||||||
@ -126,25 +163,9 @@ class _GateScheduleEdit(_GateBudgetEdit):
|
|||||||
schedule = self._read_schedule_fields()
|
schedule = self._read_schedule_fields()
|
||||||
if schedule is None:
|
if schedule is None:
|
||||||
return False
|
return False
|
||||||
if not FIRST_HOUR <= schedule.first < schedule.last <= LAST_HOUR:
|
problem = schedule_problem(schedule)
|
||||||
self._set_schedule_status(
|
if problem is not None:
|
||||||
f"First meal must be before the last, both {FIRST_HOUR}-{LAST_HOUR}.",
|
self._set_schedule_status(problem, error=True)
|
||||||
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,
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
record_schedule_change(schedule)
|
record_schedule_change(schedule)
|
||||||
self._show_schedule(current_schedule())
|
self._show_schedule(current_schedule())
|
||||||
|
|||||||
@ -18,6 +18,8 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from diet_guard._estimator import Nutrition
|
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 import log_meal
|
||||||
from diet_guard._state_today import today_entries
|
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:
|
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()
|
wanted = name.strip().casefold()
|
||||||
return any(
|
return any(
|
||||||
str(entry.get("desc", "")).strip().casefold() == wanted
|
str(entry.get("desc", "")).strip().casefold() == wanted
|
||||||
and entry.get("slot") == slot
|
and entry_slot_minute(entry) == slot
|
||||||
for entry in entries
|
for entry in entries
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -56,7 +65,7 @@ def fill_plan(slotted: Sequence[SlottedDish]) -> list[SlottedDish]:
|
|||||||
Returns:
|
Returns:
|
||||||
The dishes to log, in the order given.
|
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]
|
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)
|
log_meal(dish.name, dish_nutrition(dish), item.slot)
|
||||||
# Reflect the write locally so two identical dishes in one batch do not
|
# Reflect the write locally so two identical dishes in one batch do not
|
||||||
# both land: today_entries() was read once, before the loop.
|
# 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)
|
written.append(dish.name)
|
||||||
return written
|
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.
|
last, and doubles up the earliest slots rather than dropping anything.
|
||||||
|
|
||||||
Integer ``//`` only, never ``round()``. That is repo convention for slot
|
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
|
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.
|
other does not is a checkpoint that can never be satisfied.
|
||||||
|
|
||||||
@ -44,18 +44,19 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class SlottedDish(NamedTuple):
|
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
|
dish: Dish
|
||||||
slot: int
|
slot: int
|
||||||
|
|
||||||
|
|
||||||
def assign_slots(dishes: Sequence[Dish], slots: Sequence[int]) -> list[SlottedDish]:
|
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:
|
Args:
|
||||||
dishes: The day's dishes, in any order (sorted here by ``priority``).
|
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:
|
Returns:
|
||||||
One :class:`SlottedDish` per dish, ordered by the provider's priority.
|
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.
|
# 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_read import get_averages, get_slots, get_status, list_today
|
||||||
from diet_guard._mcp_server import APPENDS, logger, mcp
|
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._meal_schedule_store import current_schedule
|
||||||
from diet_guard._resolve import ManualMacros, resolve_nutrition
|
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 (
|
from diet_guard._state import (
|
||||||
log_meal as record_meal,
|
log_meal as record_meal,
|
||||||
)
|
)
|
||||||
from diet_guard._state import now_local
|
from diet_guard._state import now_local
|
||||||
from diet_guard._sync_events import publish_after_log
|
from diet_guard._sync_events import publish_after_log
|
||||||
|
|
||||||
|
_HOURS_PER_DAY = 24
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# Gated write tool (preview unless confirm=True; NEVER allowlist this)
|
# Gated write tool (preview unless confirm=True; NEVER allowlist this)
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@ -74,12 +78,43 @@ class Macros(BaseModel):
|
|||||||
fat: float = 0.0
|
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)
|
@mcp.tool(title="Log a meal (gated write)", annotations=APPENDS)
|
||||||
def log_meal(
|
def log_meal(
|
||||||
description: str,
|
description: str,
|
||||||
grams: float | None = None,
|
grams: float | None = None,
|
||||||
macros: Macros | None = None,
|
macros: Macros | None = None,
|
||||||
slot: int | None = None,
|
slot: int | str | None = None,
|
||||||
*,
|
*,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@ -104,15 +139,22 @@ def log_meal(
|
|||||||
macros: Manually-entered nutrition (``kcal`` plus optional
|
macros: Manually-entered nutrition (``kcal`` plus optional
|
||||||
protein/carbs/fat). When given, lookups are skipped and the meal is
|
protein/carbs/fat). When given, lookups are skipped and the meal is
|
||||||
logged exactly as specified.
|
logged exactly as specified.
|
||||||
slot: The meal-slot hour to satisfy; when omitted (or None) it falls
|
slot: The meal slot to satisfy, as ``"HH:MM"`` (e.g. ``"07:15"``) or
|
||||||
back to ``slot_for_log``, which clamps to a real hour rather than
|
a legacy int hour (e.g. ``12`` for 12:00); it must be one of
|
||||||
returning None, so the entry always satisfies some slot.
|
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.
|
confirm: Set ``True`` to actually append the entry; otherwise preview.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A preview or applied result dict; ``{"ok": false, "reason": ...}`` when
|
A preview or applied result dict; ``{"ok": false, "reason": ...}`` when
|
||||||
the food cannot be resolved or the log cannot be written.
|
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 = (
|
manual_macros = (
|
||||||
ManualMacros(
|
ManualMacros(
|
||||||
kcal=macros.kcal,
|
kcal=macros.kcal,
|
||||||
@ -132,9 +174,6 @@ def log_meal(
|
|||||||
"or Open Food Facts. Pass kcal=<number> to log it manually."
|
"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 = {
|
resolved = {
|
||||||
"kcal": nutrition.kcal,
|
"kcal": nutrition.kcal,
|
||||||
"protein_g": nutrition.protein_g,
|
"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._gate import due_slots
|
||||||
from diet_guard._mcp_server import READS_ONLY, mcp
|
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._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._slots import current_slot, day_slots, slot_label
|
||||||
from diet_guard._state import (
|
from diet_guard._state import (
|
||||||
load_log,
|
load_log,
|
||||||
@ -62,7 +63,9 @@ def _entry_view(entry: dict[str, object]) -> dict[str, Any]:
|
|||||||
"fat_g": entry.get("fat_g"),
|
"fat_g": entry.get("fat_g"),
|
||||||
"grams": entry.get("grams"),
|
"grams": entry.get("grams"),
|
||||||
"source": entry.get("source"),
|
"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
|
Reports the calories and macros *consumed* so far, the qualitative
|
||||||
:func:`consumption_band` (``"on track"`` / ``"approaching limit"`` /
|
:func:`consumption_band` (``"on track"`` / ``"approaching limit"`` /
|
||||||
``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the
|
``"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
|
meal-slot picture (which slots are due, as ``HH:MM`` labels; which are
|
||||||
current slot). The raw budget number is intentionally withheld -- only the
|
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
|
band is exposed, mirroring how the CLI status shows a label to an automated
|
||||||
caller rather than the anchor number.
|
caller rather than the anchor number.
|
||||||
"""
|
"""
|
||||||
@ -88,14 +92,15 @@ def get_status() -> dict[str, Any]:
|
|||||||
except BudgetError:
|
except BudgetError:
|
||||||
# No budget set (or a corrupt file): surface the absence, not a number.
|
# No budget set (or a corrupt file): surface the absence, not a number.
|
||||||
band = None
|
band = None
|
||||||
|
schedule = current_meal_schedule()
|
||||||
return {
|
return {
|
||||||
"consumed_kcal": today_total_kcal(),
|
"consumed_kcal": today_total_kcal(),
|
||||||
"consumed_macros_g": _macros_dict(today_total_macros()),
|
"consumed_macros_g": _macros_dict(today_total_macros()),
|
||||||
"consumption_band": band,
|
"consumption_band": band,
|
||||||
"budget_initialized": band is not None,
|
"budget_initialized": band is not None,
|
||||||
"due_slots": [slot_label(slot) for slot in due_slots()],
|
"due_slots": [slot_label(slot) for slot in due_slots()],
|
||||||
"logged_slots": sorted(logged_slots_today()),
|
"logged_slots": sorted(logged_slots_today(schedule)),
|
||||||
"current_slot": current_slot(now_local(), current_meal_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.
|
"""List today's logged meals (valid entries only), newest last.
|
||||||
|
|
||||||
Returns the per-entry description, calories, macros, portion, source, and
|
Returns the per-entry description, calories, macros, portion, source, and
|
||||||
slot -- the same data the CLI ``status`` listing renders, minus the internal
|
recorded slot minute -- the same data the CLI ``status`` listing renders,
|
||||||
HMAC signature.
|
minus the internal HMAC signature.
|
||||||
"""
|
"""
|
||||||
entries = today_entries()
|
entries = today_entries()
|
||||||
return {
|
return {
|
||||||
@ -172,15 +177,18 @@ def get_averages() -> dict[str, Any]:
|
|||||||
|
|
||||||
@mcp.tool(title="Meal slots for the day", annotations=READS_ONLY)
|
@mcp.tool(title="Meal slots for the day", annotations=READS_ONLY)
|
||||||
def get_slots() -> dict[str, Any]:
|
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
|
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()
|
schedule = current_meal_schedule()
|
||||||
return {
|
return {
|
||||||
"day_slots": [
|
"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),
|
"current_slot": current_slot(now_local(), schedule),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from diet_guard._constants import MEAL_SCHEDULE_FILE
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -65,33 +70,27 @@ class ScheduleEntry:
|
|||||||
|
|
||||||
def entry_to_json(entry: ScheduleEntry) -> dict[str, object]:
|
def entry_to_json(entry: ScheduleEntry) -> dict[str, object]:
|
||||||
"""Return the wire/disk form of one entry's value."""
|
"""Return the wire/disk form of one entry's value."""
|
||||||
return {
|
# ``schedule_to_wire`` keeps a whole-hour schedule byte-identical to the
|
||||||
"f": entry.schedule.first,
|
# pre-minute ``{"f", "l", "n"}`` form, so an older build reading this file
|
||||||
"l": entry.schedule.last,
|
# still sees the hours it understands.
|
||||||
"n": entry.schedule.count,
|
return {**schedule_to_wire(entry.schedule), "t": entry.edited_at}
|
||||||
"t": entry.edited_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def entry_from_json(effective_from: str, raw: object) -> ScheduleEntry | None:
|
def entry_from_json(effective_from: str, raw: object) -> ScheduleEntry | None:
|
||||||
"""Return an entry parsed from its stored value, or None if unusable.
|
"""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
|
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):
|
schedule = schedule_from_wire(raw)
|
||||||
return None
|
if schedule is None or not isinstance(raw, dict):
|
||||||
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)
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
edited_at = raw.get("t")
|
edited_at = raw.get("t")
|
||||||
return ScheduleEntry(
|
return ScheduleEntry(
|
||||||
effective_from=effective_from,
|
effective_from=effective_from,
|
||||||
# Normalising on the way in means a peer running a future version with
|
schedule=schedule,
|
||||||
# a wider range cannot hand us a schedule we would derive differently.
|
|
||||||
schedule=MealSchedule(first, last, count).normalized(),
|
|
||||||
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
|
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -26,9 +26,12 @@ import re
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from diet_guard._meal_schedule import MINUTES_PER_DAY, is_wire_int
|
from diet_guard._meal_schedule import MINUTES_PER_DAY, is_wire_int
|
||||||
|
from diet_guard._slots import satisfied_slots
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
# ASCII digits only: ``\d`` would also accept Arabic-Indic and other Unicode
|
||||||
# digits, which ``int()`` happily parses.
|
# digits, which ``int()`` happily parses.
|
||||||
@ -81,6 +84,30 @@ def entry_slot_minute(entry: Mapping[str, object]) -> int | None:
|
|||||||
return 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:
|
def parse_hhmm(text: str) -> int:
|
||||||
"""Parse a user-typed clock time into a minute of day.
|
"""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._coerce import as_float
|
||||||
from diet_guard._constants import FOOD_LOG_FILE
|
from diet_guard._constants import FOOD_LOG_FILE
|
||||||
|
from diet_guard._slot_wire import slot_fields
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
@ -137,9 +138,12 @@ def log_meal(
|
|||||||
Args:
|
Args:
|
||||||
description: The user's free-text meal description.
|
description: The user's free-text meal description.
|
||||||
nutrition: Estimated nutrition for the portion eaten.
|
nutrition: Estimated nutrition for the portion eaten.
|
||||||
slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the
|
slot: The meal-slot *minute of day* this entry satisfies (e.g.
|
||||||
12:00 checkpoint). When None the entry still counts toward the
|
``720`` for the 12:00 checkpoint, ``435`` for 07:15). Encoded
|
||||||
day's calories but does not mark any slot as logged.
|
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
|
components: For a composite (multi-item) meal, each component's own
|
||||||
name and macros. Carried on the log entry itself -- not just the
|
name and macros. Carried on the log entry itself -- not just the
|
||||||
food bank -- so a bank rebuilt purely by replaying the log (the
|
food bank -- so a bank rebuilt purely by replaying the log (the
|
||||||
@ -173,7 +177,9 @@ def log_meal(
|
|||||||
"source": nutrition.source,
|
"source": nutrition.source,
|
||||||
}
|
}
|
||||||
if slot is not None:
|
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:
|
if components is not None:
|
||||||
entry["components"] = list(components)
|
entry["components"] = list(components)
|
||||||
signature = compute_entry_hmac(entry)
|
signature = compute_entry_hmac(entry)
|
||||||
|
|||||||
@ -18,8 +18,12 @@ already import them from.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from diet_guard._budget import daily_budget
|
from diet_guard._budget import daily_budget
|
||||||
from diet_guard._constants import BUDGET_WARN_FRACTION
|
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 (
|
from diet_guard._state import (
|
||||||
_entry_float,
|
_entry_float,
|
||||||
_today,
|
_today,
|
||||||
@ -27,6 +31,9 @@ from diet_guard._state import (
|
|||||||
load_log,
|
load_log,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from diet_guard._meal_schedule import MealSchedule
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"consumption_band",
|
"consumption_band",
|
||||||
"logged_slots_today",
|
"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)
|
return round(protein, 1), round(carbs, 1), round(fat, 1)
|
||||||
|
|
||||||
|
|
||||||
def logged_slots_today() -> set[int]:
|
def logged_slots_today(schedule: MealSchedule | None = None) -> set[int]:
|
||||||
"""Return the set of meal-slot hours already covered by today's log.
|
"""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
|
Only valid (HMAC-verified) entries count, so stripping entries to dodge a
|
||||||
checkpoint makes that slot reappear as unsatisfied -- the fail-closed
|
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.
|
checkpoint) contributes calories but satisfies no slot.
|
||||||
|
|
||||||
Nothing produces a slot-less entry any more: the phone's "Snack" chip was
|
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
|
through ``slot_for_log`` -- the CLI (:mod:`_cli`), the gate
|
||||||
(:mod:`_gatelock_mealflow`) and the MCP tool (:mod:`_mcp`, which falls back
|
(: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
|
to it whenever its ``slot`` argument is omitted or None). Skipping
|
||||||
is still load-bearing for entries already on disk and arriving over sync --
|
slot-less entries (in ``satisfied_by_entries``) is still load-bearing for
|
||||||
removing it would make every historical snack retroactively satisfy a meal
|
entries already on disk and arriving over sync -- removing it would make
|
||||||
slot.
|
every historical snack retroactively satisfy a meal slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule: The schedule to snap onto; defaults to today's.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The distinct integer slot hours logged today (possibly empty).
|
The distinct slot minutes satisfied today (possibly empty).
|
||||||
"""
|
"""
|
||||||
slots: set[int] = set()
|
resolved = schedule if schedule is not None else current_schedule()
|
||||||
for entry in today_entries():
|
return satisfied_by_entries(today_entries(), resolved)
|
||||||
value = entry.get("slot")
|
|
||||||
if isinstance(value, int) and not isinstance(value, bool):
|
|
||||||
slots.add(value)
|
|
||||||
return slots
|
|
||||||
|
|
||||||
|
|
||||||
def remaining_budget() -> float:
|
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
|
relays them untouched. That is what makes this shippable without a
|
||||||
coordinated release.
|
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
|
``Record.to_dict``/``from_dict`` round-trip nested values unchanged, and a
|
||||||
malformed one is skipped exactly as a non-int ``hist:`` value is.
|
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 crdt_sync import Hlc
|
||||||
|
|
||||||
from diet_guard._device import device_id
|
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._meal_schedule_store import ScheduleEntry
|
||||||
from diet_guard.sync_merge._clock import _EPOCH
|
from diet_guard.sync_merge._clock import _EPOCH
|
||||||
|
|
||||||
@ -64,11 +66,7 @@ def schedule_fields(
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": (
|
f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": (
|
||||||
{
|
schedule_to_wire(entry.schedule),
|
||||||
"f": entry.schedule.first,
|
|
||||||
"l": entry.schedule.last,
|
|
||||||
"n": entry.schedule.count,
|
|
||||||
},
|
|
||||||
schedule_hlc(entry),
|
schedule_hlc(entry),
|
||||||
)
|
)
|
||||||
for entry in entries
|
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():
|
for name, (value, hlc) in record.fields.items():
|
||||||
if not name.startswith(SCHEDULE_FIELD_PREFIX):
|
if not name.startswith(SCHEDULE_FIELD_PREFIX):
|
||||||
continue
|
continue
|
||||||
if not isinstance(value, dict):
|
# Normalised on the way in, so a peer running a future version with a
|
||||||
continue
|
# wider range cannot hand us a schedule we would derive slots
|
||||||
first, last, count = value.get("f"), value.get("l"), value.get("n")
|
# differently from.
|
||||||
if not (
|
schedule = schedule_from_wire(value)
|
||||||
isinstance(first, int) and isinstance(last, int) and isinstance(count, int)
|
if schedule is None:
|
||||||
) or any(isinstance(part, bool) for part in (first, last, count)):
|
|
||||||
continue
|
continue
|
||||||
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
|
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
|
||||||
entries.append(
|
entries.append(
|
||||||
ScheduleEntry(
|
ScheduleEntry(
|
||||||
effective_from=name[len(SCHEDULE_FIELD_PREFIX) :],
|
effective_from=name[len(SCHEDULE_FIELD_PREFIX) :],
|
||||||
# Normalised on the way in, so a peer running a future version
|
schedule=schedule,
|
||||||
# with a wider range cannot hand us a schedule we would derive
|
|
||||||
# slots differently from.
|
|
||||||
schedule=MealSchedule(first, last, count).normalized(),
|
|
||||||
edited_at=edited.astimezone().isoformat(timespec="seconds"),
|
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(module, "tk", _FAKE_TK))
|
||||||
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
|
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
|
||||||
stack.enter_context(patch.object(_gatelock_calendar_ui, "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
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar
|
|||||||
from diet_guard.tests._tk_fakes_containers import (
|
from diet_guard.tests._tk_fakes_containers import (
|
||||||
FakeNotebook,
|
FakeNotebook,
|
||||||
FakeRadiobutton,
|
FakeRadiobutton,
|
||||||
|
FakeSpinbox,
|
||||||
FakeStyle,
|
FakeStyle,
|
||||||
FakeWidget,
|
FakeWidget,
|
||||||
)
|
)
|
||||||
@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"FakeNotebook",
|
"FakeNotebook",
|
||||||
"FakeRadiobutton",
|
"FakeRadiobutton",
|
||||||
"FakeScrollbar",
|
"FakeScrollbar",
|
||||||
|
"FakeSpinbox",
|
||||||
"FakeStyle",
|
"FakeStyle",
|
||||||
"FakeText",
|
"FakeText",
|
||||||
"FakeVar",
|
"FakeVar",
|
||||||
@ -174,7 +176,7 @@ _FAKE_TK = SimpleNamespace(
|
|||||||
Event=object,
|
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``
|
# 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
|
# independently; all of them must see the fake so ``tk.TclError`` etc. are the
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from __future__ import annotations
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"FakeNotebook",
|
"FakeNotebook",
|
||||||
"FakeRadiobutton",
|
"FakeRadiobutton",
|
||||||
|
"FakeSpinbox",
|
||||||
"FakeStyle",
|
"FakeStyle",
|
||||||
"FakeWidget",
|
"FakeWidget",
|
||||||
]
|
]
|
||||||
@ -181,3 +182,28 @@ class FakeStyle:
|
|||||||
|
|
||||||
def map(self, style_name: str, **kwargs: object) -> None:
|
def map(self, style_name: str, **kwargs: object) -> None:
|
||||||
self.configured.setdefault(style_name, {}).update(kwargs)
|
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:
|
def test_slot_status_all_marks(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
"""The slot line shows logged / DUE / upcoming together."""
|
"""The slot line shows logged / DUE / upcoming together."""
|
||||||
with (
|
with (
|
||||||
patch.object(_cli, "logged_slots_today", return_value={8}),
|
patch.object(_cli, "logged_slots_today", return_value={480}),
|
||||||
patch.object(_cli, "due_slots", return_value=[12]),
|
patch.object(_cli, "due_slots", return_value=[720]),
|
||||||
):
|
):
|
||||||
_cli._print_slot_status()
|
_cli._print_slot_status()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
@ -13,6 +13,8 @@ from diet_guard import _cli_log
|
|||||||
from diet_guard._budget import write_budget
|
from diet_guard._budget import write_budget
|
||||||
from diet_guard._cli import main
|
from diet_guard._cli import main
|
||||||
from diet_guard._estimator import Nutrition
|
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
|
from diet_guard._state import load_log, now_local
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -57,3 +59,32 @@ class TestAteFutureDate:
|
|||||||
yesterday = (now_local() - timedelta(days=1)).date().isoformat()
|
yesterday = (now_local() - timedelta(days=1)).date().isoformat()
|
||||||
assert main(["ate", "x", "--date", yesterday, "--hour", "12"]) == 1
|
assert main(["ate", "x", "--date", yesterday, "--hour", "12"]) == 1
|
||||||
assert "not a future date" in capsys.readouterr().out
|
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."""
|
"""At 09:00 only the 08:00 slot is due; logging it flips the decision."""
|
||||||
now = datetime(2026, 1, 1, 9, 0, tzinfo=UTC)
|
now = datetime(2026, 1, 1, 9, 0, tzinfo=UTC)
|
||||||
assert gate_is_due(now) is True
|
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
|
assert gate_is_due(now) is False
|
||||||
|
|||||||
@ -36,8 +36,8 @@ class TestDueSlots:
|
|||||||
|
|
||||||
def test_injected_now(self) -> None:
|
def test_injected_now(self) -> None:
|
||||||
"""With 08:00 logged at 13:00, only 12:00 is due."""
|
"""With 08:00 logged at 13:00, only 12:00 is due."""
|
||||||
with _logged({8}):
|
with _logged({480}):
|
||||||
assert due_slots(_at(13)) == (12,)
|
assert due_slots(_at(13)) == (720,)
|
||||||
|
|
||||||
def test_default_now_uses_clock(self) -> None:
|
def test_default_now_uses_clock(self) -> None:
|
||||||
"""Omitting ``now`` reads the real clock (mocked here for determinism)."""
|
"""Omitting ``now`` reads the real clock (mocked here for determinism)."""
|
||||||
@ -48,7 +48,7 @@ class TestDueSlots:
|
|||||||
return_value=_at(9),
|
return_value=_at(9),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
assert due_slots() == (8,)
|
assert due_slots() == (480,)
|
||||||
|
|
||||||
|
|
||||||
class TestGateIsDue:
|
class TestGateIsDue:
|
||||||
@ -61,7 +61,7 @@ class TestGateIsDue:
|
|||||||
|
|
||||||
def test_not_due_when_all_logged(self) -> None:
|
def test_not_due_when_all_logged(self) -> None:
|
||||||
"""Everything elapsed is logged -> no lock."""
|
"""Everything elapsed is logged -> no lock."""
|
||||||
with _logged({8, 12}):
|
with _logged({480, 720}):
|
||||||
assert gate_is_due(_at(13)) is False
|
assert gate_is_due(_at(13)) is False
|
||||||
|
|
||||||
|
|
||||||
@ -70,12 +70,12 @@ class TestGateMessage:
|
|||||||
|
|
||||||
def test_all_logged(self) -> None:
|
def test_all_logged(self) -> None:
|
||||||
"""Nothing missing -> the up-to-date message."""
|
"""Nothing missing -> the up-to-date message."""
|
||||||
with _logged({8, 12}):
|
with _logged({480, 720}):
|
||||||
assert "up to date" in gate_message(_at(13))
|
assert "up to date" in gate_message(_at(13))
|
||||||
|
|
||||||
def test_single_missing(self) -> None:
|
def test_single_missing(self) -> None:
|
||||||
"""One missing slot -> singular phrasing."""
|
"""One missing slot -> singular phrasing."""
|
||||||
with _logged({8}):
|
with _logged({480}):
|
||||||
assert gate_message(_at(13)) == "Log your 12:00 meal to unlock."
|
assert gate_message(_at(13)) == "Log your 12:00 meal to unlock."
|
||||||
|
|
||||||
def test_multiple_missing(self) -> None:
|
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 (
|
from diet_guard import (
|
||||||
_gatelock_calendar,
|
_gatelock_calendar,
|
||||||
|
_gatelock_calendar_schedule,
|
||||||
_gatelock_calendar_ui,
|
_gatelock_calendar_ui,
|
||||||
_gatelock_ui,
|
_gatelock_ui,
|
||||||
_gatelock_widgetgroups,
|
_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 not wrong, f"fake_tk() left real tkinter in place for: {wrong}"
|
||||||
assert _gatelock_calendar.ttk is _FAKE_TTK
|
assert _gatelock_calendar.ttk is _FAKE_TTK
|
||||||
assert _gatelock_calendar_ui.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.
|
# ...and is restored afterwards, so the fake cannot leak into other tests.
|
||||||
assert _gatelock_ui.tk is tk
|
assert _gatelock_ui.tk is tk
|
||||||
|
|||||||
@ -69,8 +69,8 @@ class TestModuleHelpers:
|
|||||||
|
|
||||||
def test_pending_slots_due(self) -> None:
|
def test_pending_slots_due(self) -> None:
|
||||||
"""When slots are due, those are returned verbatim."""
|
"""When slots are due, those are returned verbatim."""
|
||||||
with patch.object(_gatelock, "due_slots", return_value=[12, 16]):
|
with patch.object(_gatelock, "due_slots", return_value=[720, 960]):
|
||||||
assert _pending_slots(demo_mode=False) == [12, 16]
|
assert _pending_slots(demo_mode=False) == [720, 960]
|
||||||
|
|
||||||
def test_pending_slots_demo_fallback(self) -> None:
|
def test_pending_slots_demo_fallback(self) -> None:
|
||||||
"""Demo mode invents a representative slot when nothing is due."""
|
"""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._pending = []
|
||||||
gate._refresh_slot_header()
|
gate._refresh_slot_header()
|
||||||
assert "All meals logged" in gate._vars.slot_header.get()
|
assert "All meals logged" in gate._vars.slot_header.get()
|
||||||
gate._pending = [8]
|
gate._pending = [480]
|
||||||
gate._refresh_slot_header()
|
gate._refresh_slot_header()
|
||||||
assert "Log your" in gate._vars.slot_header.get()
|
assert "Log your" in gate._vars.slot_header.get()
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
gate._refresh_slot_header()
|
gate._refresh_slot_header()
|
||||||
assert "remaining" in gate._vars.slot_header.get()
|
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:
|
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
|
||||||
"""A demo gate tags logs with no real slot."""
|
"""A demo gate tags logs with no real slot."""
|
||||||
gate._pending = [8]
|
gate._pending = [480]
|
||||||
assert gate._slot_for_log() is None
|
assert gate._slot_for_log() is None
|
||||||
|
|
||||||
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
|
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
|
||||||
"""A production gate tags logs with the current slot."""
|
"""A production gate tags logs with the current slot."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [12]
|
gate._pending = [720]
|
||||||
assert gate._slot_for_log() == 12
|
assert gate._slot_for_log() == 720
|
||||||
|
|
||||||
def test_clear_inputs_resets_the_form(self, gate: MealGate) -> None:
|
def test_clear_inputs_resets_the_form(self, gate: MealGate) -> None:
|
||||||
"""Clearing between slots empties the description and the macros."""
|
"""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:
|
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
|
||||||
"""Finishing the final slot triggers unlock."""
|
"""Finishing the final slot triggers unlock."""
|
||||||
gate._pending = [20]
|
gate._pending = [1200]
|
||||||
with patch.object(gate, "_unlock") as unlock:
|
with patch.object(gate, "_unlock") as unlock:
|
||||||
gate._finish_slot("done")
|
gate._finish_slot("done")
|
||||||
unlock.assert_called_once()
|
unlock.assert_called_once()
|
||||||
@ -126,7 +126,7 @@ class TestFetchFromSync:
|
|||||||
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
|
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
|
||||||
"""A failed pull shows the reason and leaves pending slots intact."""
|
"""A failed pull shows the reason and leaves pending slots intact."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
with patch.object(
|
with patch.object(
|
||||||
_gatelock_delivery,
|
_gatelock_delivery,
|
||||||
"pull_peer_logs",
|
"pull_peer_logs",
|
||||||
@ -134,52 +134,52 @@ class TestFetchFromSync:
|
|||||||
):
|
):
|
||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [8, 12]
|
assert gate._pending == [480, 720]
|
||||||
assert "still locked" in gate._vars.status.get()
|
assert "still locked" in gate._vars.status.get()
|
||||||
|
|
||||||
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
|
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
|
||||||
"""A clean pull that satisfies nothing reports so and keeps the slots."""
|
"""A clean pull that satisfies nothing reports so and keeps the slots."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
|
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()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [8, 12]
|
assert gate._pending == [480, 720]
|
||||||
assert "No new meals" in gate._vars.status.get()
|
assert "No new meals" in gate._vars.status.get()
|
||||||
|
|
||||||
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
|
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
|
||||||
"""One slot pulled in leaves the rest; the window advances (singular)."""
|
"""One slot pulled in leaves the rest; the window advances (singular)."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
|
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()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [12]
|
assert gate._pending == [720]
|
||||||
assert "Pulled 1 meal " in gate._vars.status.get()
|
assert "Pulled 1 meal " in gate._vars.status.get()
|
||||||
|
|
||||||
def test_partial_plural_wording(self, gate: MealGate) -> None:
|
def test_partial_plural_wording(self, gate: MealGate) -> None:
|
||||||
"""Two slots pulled in uses the plural 'meals'."""
|
"""Two slots pulled in uses the plural 'meals'."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [8, 12, 16]
|
gate._pending = [480, 720, 960]
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
|
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()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [16]
|
assert gate._pending == [960]
|
||||||
assert "Pulled 2 meals" in gate._vars.status.get()
|
assert "Pulled 2 meals" in gate._vars.status.get()
|
||||||
|
|
||||||
def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
|
def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
|
||||||
"""When the pull satisfies every pending slot, the gate unlocks."""
|
"""When the pull satisfies every pending slot, the gate unlocks."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
|
patch.object(_gatelock_delivery, "pull_peer_logs", return_value=None),
|
||||||
patch.object(_gatelock_delivery, "due_slots", return_value=()),
|
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 __future__ import annotations
|
||||||
|
|
||||||
from contextlib import ExitStack
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from diet_guard import _gatelock_calendar
|
|
||||||
from diet_guard._gatelock import MealGate
|
from diet_guard._gatelock import MealGate
|
||||||
from diet_guard.tests.conftest import (
|
from diet_guard.tests._gate_fixtures import fake_tk
|
||||||
_FAKE_TK,
|
from diet_guard.tests.conftest import TWO_OUTPUTS
|
||||||
_FAKE_TTK,
|
|
||||||
_GATE_TK_MODULES,
|
|
||||||
TWO_OUTPUTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@ -32,10 +26,10 @@ if TYPE_CHECKING:
|
|||||||
def dual_gate(dual_output: None) -> Iterator[MealGate]:
|
def dual_gate(dual_output: None) -> Iterator[MealGate]:
|
||||||
"""A demo gate built across two live outputs."""
|
"""A demo gate built across two live outputs."""
|
||||||
del dual_output
|
del dual_output
|
||||||
with ExitStack() as stack:
|
# The shared patch set, never a hand-picked copy: a copy silently misses
|
||||||
for module in _GATE_TK_MODULES:
|
# the next module that starts building widgets (the schedule row's ttk
|
||||||
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
|
# time fields were exactly that).
|
||||||
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
|
with fake_tk():
|
||||||
yield MealGate(demo_mode=True)
|
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._gatelock_kuchnia import DeliveryResult
|
||||||
from diet_guard._kuchnia_parse import Dish
|
from diet_guard._kuchnia_parse import Dish
|
||||||
from diet_guard._meal_schedule_store import current_schedule
|
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._slots import day_slots
|
||||||
from diet_guard._state import log_meal, now_local
|
from diet_guard._state import log_meal, now_local
|
||||||
from diet_guard._state_today import today_entries
|
from diet_guard._state_today import today_entries
|
||||||
@ -89,10 +90,11 @@ def delivered(*dishes: Dish) -> DeliveryResult:
|
|||||||
|
|
||||||
|
|
||||||
def logged() -> dict[int, list[str]]:
|
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]] = {}
|
by_slot: dict[int, list[str]] = {}
|
||||||
for entry in today_entries():
|
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
|
return by_slot
|
||||||
|
|
||||||
|
|
||||||
@ -104,7 +106,7 @@ def propose(h: Harness) -> None:
|
|||||||
|
|
||||||
def test_default_schedule_precondition() -> None:
|
def test_default_schedule_precondition() -> None:
|
||||||
# Every expectation below assumes the redirected (default) schedule.
|
# 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:
|
class TestFirstClick:
|
||||||
@ -160,7 +162,7 @@ class TestFirstClick:
|
|||||||
assert h.label.get() == FILL_LABEL
|
assert h.label.get() == FILL_LABEL
|
||||||
|
|
||||||
def test_every_slot_already_logged(self) -> None:
|
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)
|
log_meal(f"mine {slot}", _nutrition(), slot)
|
||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
@ -169,7 +171,7 @@ class TestFirstClick:
|
|||||||
assert h.label.get() == FILL_LABEL
|
assert h.label.get() == FILL_LABEL
|
||||||
|
|
||||||
def test_the_proposal_writes_nothing(self) -> None:
|
def test_the_proposal_writes_nothing(self) -> None:
|
||||||
log_meal("my sandwich", _nutrition(), 12)
|
log_meal("my sandwich", _nutrition(), 720)
|
||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
propose(h)
|
propose(h)
|
||||||
@ -178,23 +180,23 @@ class TestFirstClick:
|
|||||||
"Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, "
|
"Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, "
|
||||||
"20:00 Kolacja — click Confirm."
|
"20:00 Kolacja — click Confirm."
|
||||||
)
|
)
|
||||||
assert logged() == {12: ["my sandwich"]}
|
assert logged() == {720: ["my sandwich"]}
|
||||||
h.on_logged.assert_not_called()
|
h.on_logged.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestConfirm:
|
class TestConfirm:
|
||||||
def test_confirm_fills_only_the_empty_slots(self) -> None:
|
def test_confirm_fills_only_the_empty_slots(self) -> None:
|
||||||
log_meal("my sandwich", _nutrition(), 12)
|
log_meal("my sandwich", _nutrition(), 720)
|
||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)) as start:
|
with fetching(delivered(*FOUR)) as start:
|
||||||
propose(h)
|
propose(h)
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert start.call_count == 1 # the confirm did not refetch
|
assert start.call_count == 1 # the confirm did not refetch
|
||||||
assert logged() == {
|
assert logged() == {
|
||||||
8: ["Owsianka"],
|
480: ["Owsianka"],
|
||||||
12: ["my sandwich"],
|
720: ["my sandwich"],
|
||||||
16: ["Obiad"],
|
960: ["Obiad"],
|
||||||
20: ["Kolacja"],
|
1200: ["Kolacja"],
|
||||||
}
|
}
|
||||||
assert h.status == "Logged 3 catering dish(es)."
|
assert h.status == "Logged 3 catering dish(es)."
|
||||||
h.on_logged.assert_called_once_with()
|
h.on_logged.assert_called_once_with()
|
||||||
@ -207,7 +209,7 @@ class TestConfirm:
|
|||||||
with fetching(delivered(*five)):
|
with fetching(delivered(*five)):
|
||||||
propose(h)
|
propose(h)
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert logged()[8] == ["Owsianka", "Wrap"]
|
assert logged()[480] == ["Owsianka", "Wrap"]
|
||||||
assert h.status == "Logged 5 catering dish(es)."
|
assert h.status == "Logged 5 catering dish(es)."
|
||||||
|
|
||||||
def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None:
|
def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None:
|
||||||
@ -215,16 +217,16 @@ class TestConfirm:
|
|||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
propose(h)
|
propose(h)
|
||||||
assert h.label.get() == "✓ Confirm (4)"
|
assert h.label.get() == "✓ Confirm (4)"
|
||||||
log_meal("toast", _nutrition(), 8)
|
log_meal("toast", _nutrition(), 480)
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert logged()[8] == ["toast"]
|
assert logged()[480] == ["toast"]
|
||||||
assert h.status == "Logged 3 catering dish(es)."
|
assert h.status == "Logged 3 catering dish(es)."
|
||||||
|
|
||||||
def test_every_slot_taken_between_the_clicks(self) -> None:
|
def test_every_slot_taken_between_the_clicks(self) -> None:
|
||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
propose(h)
|
propose(h)
|
||||||
for slot in (8, 12, 16, 20):
|
for slot in (480, 720, 960, 1200):
|
||||||
log_meal(f"mine {slot}", _nutrition(), slot)
|
log_meal(f"mine {slot}", _nutrition(), slot)
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert h.status == "Every slot already has a meal — nothing to fill."
|
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):
|
with patch.object(_gatelock_fillall, "now_local", return_value=tomorrow):
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert start.call_count == 2
|
assert start.call_count == 2
|
||||||
assert start.call_args.args == (
|
refetch = (_gatelock_fillall._refresh_delivery, tomorrow.date())
|
||||||
_gatelock_fillall._refresh_delivery,
|
assert start.call_args.args == refetch
|
||||||
tomorrow.date(),
|
|
||||||
)
|
|
||||||
assert logged() == {}
|
assert logged() == {}
|
||||||
h.on_logged.assert_not_called()
|
h.on_logged.assert_not_called()
|
||||||
assert h.label.get() == FILL_LABEL
|
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_mealflow import _UNLOCK_DELAY_MS
|
||||||
from diet_guard._gatelock_ui import GateCallbacks, make_vars
|
from diet_guard._gatelock_ui import GateCallbacks, make_vars
|
||||||
from diet_guard._state import log_meal, now_local
|
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._gate_fixtures import fake_tk
|
||||||
from diet_guard.tests.conftest import _nutrition
|
from diet_guard.tests.conftest import _nutrition
|
||||||
from diet_guard.tests.test_gatelock_fillall import (
|
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."""
|
"""``due_slots`` as the log on disk says, so an unlock needs a real write."""
|
||||||
|
|
||||||
def due() -> tuple[int, ...]:
|
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 tuple(slot for slot in pending if slot not in taken)
|
||||||
|
|
||||||
return due
|
return due
|
||||||
@ -86,29 +86,29 @@ class TestTheGateDrivesTheFlow:
|
|||||||
|
|
||||||
def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None:
|
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.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
|
# Fill all also banks the dishes for one-at-a-time offering; the
|
||||||
# unlock line must not then call them "still delivered".
|
# unlock line must not then call them "still delivered".
|
||||||
gate._delivery_pending = FOUR
|
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):
|
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
|
||||||
_fill(gate, delivered(*FOUR))
|
_fill(gate, delivered(*FOUR))
|
||||||
status = gate._vars.status.get()
|
status = gate._vars.status.get()
|
||||||
assert status == "Filled from catering — all meals logged, unlocking…"
|
assert status == "Filled from catering — all meals logged, unlocking…"
|
||||||
assert gate._pending == []
|
assert gate._pending == []
|
||||||
gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close)
|
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
|
assert gate._vars.fill_label.get() == FILL_LABEL
|
||||||
|
|
||||||
def test_a_partial_fill_advances_instead_of_unlocking(self, gate: MealGate) -> None:
|
def test_a_partial_fill_advances_instead_of_unlocking(self, gate: MealGate) -> None:
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
due = _due_from_log([8, 12])
|
due = _due_from_log([480, 720])
|
||||||
# Two dishes spread onto 08:00 and 16:00: 12:00 is still owed.
|
# Two dishes spread onto 08:00 and 16:00: 12:00 is still owed.
|
||||||
two = (dish("Owsianka", 1), dish("Obiad", 2))
|
two = (dish("Owsianka", 1), dish("Obiad", 2))
|
||||||
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
|
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
|
||||||
_fill(gate, delivered(*two))
|
_fill(gate, delivered(*two))
|
||||||
assert gate._pending == [12]
|
assert gate._pending == [720]
|
||||||
assert "Pulled 1 meal " in gate._vars.status.get()
|
assert "Pulled 1 meal " in gate._vars.status.get()
|
||||||
assert "unlocking" not 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:
|
def test_demo_logs_without_slot(self, gate: MealGate) -> None:
|
||||||
"""A demo record banks the food but tags no real slot."""
|
"""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:
|
with patch.object(_gatelock_mealflow, "log_meal") as log:
|
||||||
gate._record("apple", _nutrition(95, 100))
|
gate._record("apple", _nutrition(95, 100))
|
||||||
assert log.call_args.args[2] is None
|
assert log.call_args.args[2] is None
|
||||||
|
|
||||||
def test_last_slot_unlocks(self, gate: MealGate) -> None:
|
def test_last_slot_unlocks(self, gate: MealGate) -> None:
|
||||||
"""Recording the final pending slot triggers the unlock."""
|
"""Recording the final pending slot triggers the unlock."""
|
||||||
gate._pending = [8]
|
gate._pending = [480]
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_mealflow, "log_meal"),
|
patch.object(_gatelock_mealflow, "log_meal"),
|
||||||
patch.object(_gatelock_mealflow, "remember_food"),
|
patch.object(_gatelock_mealflow, "remember_food"),
|
||||||
@ -212,13 +212,13 @@ class TestRecord:
|
|||||||
|
|
||||||
def test_more_slots_continue(self, gate: MealGate) -> None:
|
def test_more_slots_continue(self, gate: MealGate) -> None:
|
||||||
"""With slots remaining, the form clears and prompts the next."""
|
"""With slots remaining, the form clears and prompts the next."""
|
||||||
gate._pending = [8, 12]
|
gate._pending = [480, 720]
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_mealflow, "log_meal"),
|
patch.object(_gatelock_mealflow, "log_meal"),
|
||||||
patch.object(_gatelock_mealflow, "remember_food"),
|
patch.object(_gatelock_mealflow, "remember_food"),
|
||||||
):
|
):
|
||||||
gate._record("apple", _nutrition(95, 100))
|
gate._record("apple", _nutrition(95, 100))
|
||||||
assert gate._pending == [12]
|
assert gate._pending == [720]
|
||||||
assert "next meal" in gate._vars.status.get()
|
assert "next meal" in gate._vars.status.get()
|
||||||
|
|
||||||
def test_unlock_schedules_close(self, gate: MealGate) -> None:
|
def test_unlock_schedules_close(self, gate: MealGate) -> None:
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
"""Tests for the gate History tab's meal-schedule row.
|
"""Tests for the gate History tab's meal-schedule row.
|
||||||
|
|
||||||
Covers :mod:`._gatelock_scheduleedit`. The functional fake ``tk`` widgets and
|
Covers :mod:`._gatelock_scheduleedit` and the row's widgets in
|
||||||
the ``gate`` fixture live in ``conftest.py``, shared with the other gate
|
:mod:`._gatelock_calendar_schedule`. The functional fake ``tk``/``ttk``
|
||||||
tests.
|
widgets and the ``gate`` fixture live in ``conftest.py``, shared with the
|
||||||
|
other gate tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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 import DEFAULT_SCHEDULE, MealSchedule
|
||||||
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
|
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:
|
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.first.set(first)
|
||||||
gate._cal_vars.schedule.last.set(last)
|
gate._cal_vars.schedule.last.set(last)
|
||||||
gate._cal_vars.schedule.count.set(count)
|
gate._cal_vars.schedule.count.set(count)
|
||||||
|
|
||||||
|
|
||||||
|
def _status(gate: MealGate) -> str:
|
||||||
|
return gate._cal_vars.schedule.status.get()
|
||||||
|
|
||||||
|
|
||||||
class TestScheduleSummary:
|
class TestScheduleSummary:
|
||||||
"""The derived-times label."""
|
"""The derived-times label."""
|
||||||
|
|
||||||
def test_lists_every_checkpoint(self) -> None:
|
def test_lists_every_checkpoint(self) -> None:
|
||||||
"""The label spells out the hours the schedule derives."""
|
"""The label spells out the times the schedule derives."""
|
||||||
assert schedule_summary(MealSchedule(8, 20, 5)) == (
|
assert schedule_summary(MealSchedule(480, 1200, 5)) == (
|
||||||
"08:00 11:00 14:00 17:00 20:00"
|
"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:
|
class TestEditToggle:
|
||||||
"""The Edit/Save button's two states."""
|
"""The Edit/Save button's two states."""
|
||||||
@ -47,71 +59,95 @@ class TestEditToggle:
|
|||||||
def test_second_click_persists_and_relocks(self, gate: MealGate) -> None:
|
def test_second_click_persists_and_relocks(self, gate: MealGate) -> None:
|
||||||
"""Clicking Save validates, writes, and returns to read-only."""
|
"""Clicking Save validates, writes, and returns to read-only."""
|
||||||
gate._on_edit_or_save_schedule()
|
gate._on_edit_or_save_schedule()
|
||||||
_type(gate, "8", "20", "5")
|
_type(gate, "07:15", "19:00", "5")
|
||||||
gate._on_edit_or_save_schedule()
|
gate._on_edit_or_save_schedule()
|
||||||
|
|
||||||
assert not gate._cal_editing_schedule
|
assert not gate._cal_editing_schedule
|
||||||
assert current_schedule() == MealSchedule(8, 20, 5)
|
assert current_schedule() == MealSchedule(435, 1140, 5)
|
||||||
assert gate._cal_vars.schedule.status.get() == "Saved."
|
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:
|
def test_a_failed_save_leaves_editing_open(self, gate: MealGate) -> None:
|
||||||
"""A bad value can be corrected rather than silently discarded."""
|
"""A bad value can be corrected rather than silently discarded."""
|
||||||
gate._on_edit_or_save_schedule()
|
gate._on_edit_or_save_schedule()
|
||||||
_type(gate, "20", "8", "4")
|
_type(gate, "20:00", "08:00", "4")
|
||||||
gate._on_edit_or_save_schedule()
|
gate._on_edit_or_save_schedule()
|
||||||
|
|
||||||
assert gate._cal_editing_schedule
|
assert gate._cal_editing_schedule
|
||||||
assert current_schedule() == DEFAULT_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:
|
class TestValidation:
|
||||||
"""Rejected input, with the reason shown rather than silently clamped."""
|
"""Rejected input, with the reason shown rather than silently clamped."""
|
||||||
|
|
||||||
def test_rejects_non_numeric(self, gate: MealGate) -> None:
|
@pytest.mark.parametrize("first", ["eight", "7:5", "24:00", "07:60", ""])
|
||||||
"""Letters in an hour field are refused, not coerced."""
|
def test_rejects_a_time_that_is_not_hhmm(self, gate: MealGate, first: str) -> None:
|
||||||
_type(gate, "eight", "20", "4")
|
_type(gate, first, "20:00", "4")
|
||||||
|
|
||||||
assert not gate._save_schedule_entry()
|
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:
|
def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None:
|
||||||
"""The last meal must come after the first."""
|
_type(gate, "08:00", "20:00", "five")
|
||||||
_type(gate, "20", "8", "4")
|
|
||||||
|
|
||||||
assert not gate._save_schedule_entry()
|
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:
|
@pytest.mark.parametrize(
|
||||||
"""Hours outside 0-23 are refused."""
|
("first", "last", "count", "reason"),
|
||||||
_type(gate, "8", "25", "4")
|
[
|
||||||
|
("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 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:
|
def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None:
|
||||||
"""One meal a day is below the supported range."""
|
"""15 min holds two meals; 45 min holds four (one per quarter hour)."""
|
||||||
_type(gate, "8", "20", "1")
|
_type(gate, "08:00", "08:45", "4")
|
||||||
|
|
||||||
assert not gate._save_schedule_entry()
|
assert gate._save_schedule_entry()
|
||||||
assert "Meals per day" in gate._cal_vars.schedule.status.get()
|
assert current_schedule().slots() == (480, 495, 510, 525)
|
||||||
|
|
||||||
def test_rejects_too_many_meals(self, gate: MealGate) -> None:
|
def test_an_accepted_schedule_is_stored_unchanged(self) -> None:
|
||||||
"""More than the cap is refused."""
|
"""No rule here is looser than ``normalized()``: what passes is kept."""
|
||||||
_type(gate, "8", "20", "9")
|
for first in range(0, 1440, 37):
|
||||||
|
for last in range(first + 15, 1440, 53):
|
||||||
assert not gate._save_schedule_entry()
|
for count in range(2, 7):
|
||||||
assert "Meals per day" in gate._cal_vars.schedule.status.get()
|
schedule = MealSchedule(first, last, count)
|
||||||
|
if schedule_problem(schedule) is None:
|
||||||
def test_rejects_more_meals_than_the_window_holds(self, gate: MealGate) -> None:
|
assert schedule.normalized() == schedule
|
||||||
"""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()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisplay:
|
class TestDisplay:
|
||||||
@ -119,27 +155,27 @@ class TestDisplay:
|
|||||||
|
|
||||||
def test_shows_the_stored_schedule(self, gate: MealGate) -> None:
|
def test_shows_the_stored_schedule(self, gate: MealGate) -> None:
|
||||||
"""The fields and the derived-times label reflect what is stored."""
|
"""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.first.get() == "07:15"
|
||||||
assert gate._cal_vars.schedule.last.get() == "21"
|
assert gate._cal_vars.schedule.last.get() == "21:00"
|
||||||
assert gate._cal_vars.schedule.count.get() == "3"
|
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:
|
def test_a_refresh_does_not_clobber_an_open_edit(self, gate: MealGate) -> None:
|
||||||
"""Typing survives a calendar refresh landing mid-edit."""
|
"""Typing survives a calendar refresh landing mid-edit."""
|
||||||
gate._on_edit_or_save_schedule()
|
gate._on_edit_or_save_schedule()
|
||||||
_type(gate, "6", "22", "6")
|
_type(gate, "06:00", "22:00", "6")
|
||||||
gate._refresh_calendar()
|
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(
|
def test_a_refresh_shows_the_stored_schedule_when_idle(
|
||||||
self, gate: MealGate
|
self, gate: MealGate
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Outside an edit the row tracks whatever is stored."""
|
"""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()
|
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"
|
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:
|
class TestFillPlan:
|
||||||
def test_an_empty_day_fills_every_slot(self) -> None:
|
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
|
assert fill_plan(plan) == plan
|
||||||
|
|
||||||
def test_a_slot_with_a_meal_keeps_it(self) -> None:
|
def test_a_slot_with_a_meal_keeps_it(self) -> None:
|
||||||
_eat("apple", 12)
|
_eat("apple", 720)
|
||||||
plan = fill_plan([_slotted("A", 8), _slotted("B", 12), _slotted("C", 20)])
|
plan = fill_plan([_slotted("A", 480), _slotted("B", 720), _slotted("C", 1200)])
|
||||||
assert [item.slot for item in plan] == [8, 20]
|
assert [item.slot for item in plan] == [480, 1200]
|
||||||
|
|
||||||
def test_two_dishes_sharing_an_empty_slot_both_land(self) -> None:
|
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,
|
# 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.
|
# 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"]
|
assert log_dishes(plan) == ["A", "B"]
|
||||||
|
|
||||||
def test_a_full_day_fills_nothing(self) -> None:
|
def test_a_full_day_fills_nothing(self) -> None:
|
||||||
for slot in (8, 12):
|
for slot in (480, 720):
|
||||||
_eat("apple", slot)
|
_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:
|
class TestCliFill:
|
||||||
def test_log_skips_a_taken_slot(self) -> None:
|
def test_log_skips_a_taken_slot(self) -> None:
|
||||||
# Two dishes on the default four slots spread to 08:00 and 16:00.
|
# Two dishes on the default four slots spread to 08:00 and 16:00.
|
||||||
_eat("apple", 8)
|
_eat("apple", 480)
|
||||||
dishes = [
|
dishes = [
|
||||||
replace(_slotted("Owsianka", 8).dish, priority=1),
|
replace(_slotted("Owsianka", 480).dish, priority=1),
|
||||||
replace(_slotted("Kaszotto", 16).dish, priority=2),
|
replace(_slotted("Kaszotto", 960).dish, priority=2),
|
||||||
]
|
]
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
with patch.object(
|
with patch.object(
|
||||||
|
|||||||
@ -194,8 +194,8 @@ class TestRefreshOnce:
|
|||||||
class TestLogDishes:
|
class TestLogDishes:
|
||||||
def test_logs_each_dish_into_its_slot(self) -> None:
|
def test_logs_each_dish_into_its_slot(self) -> None:
|
||||||
chosen = [
|
chosen = [
|
||||||
SlottedDish(dish=_dish("A", kcal=100.0), slot=8),
|
SlottedDish(dish=_dish("A", kcal=100.0), slot=480),
|
||||||
SlottedDish(dish=_dish("B", kcal=200.0), slot=12),
|
SlottedDish(dish=_dish("B", kcal=200.0), slot=720),
|
||||||
]
|
]
|
||||||
assert log_dishes(chosen) == ["A", "B"]
|
assert log_dishes(chosen) == ["A", "B"]
|
||||||
entries = today_entries()
|
entries = today_entries()
|
||||||
@ -203,33 +203,41 @@ class TestLogDishes:
|
|||||||
assert today_total_kcal() == 300.0
|
assert today_total_kcal() == 300.0
|
||||||
|
|
||||||
def test_records_the_provenance(self) -> None:
|
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()
|
(entry,) = today_entries()
|
||||||
assert entry["source"] == "kuchnia wikinga"
|
assert entry["source"] == "kuchnia wikinga"
|
||||||
|
|
||||||
def test_a_second_run_logs_nothing(self) -> None:
|
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)
|
log_dishes(chosen)
|
||||||
assert log_dishes(chosen) == []
|
assert log_dishes(chosen) == []
|
||||||
assert len(today_entries()) == 1
|
assert len(today_entries()) == 1
|
||||||
|
|
||||||
def test_the_same_dish_in_a_different_slot_is_a_separate_meal(self) -> None:
|
def test_the_same_dish_in_a_different_slot_is_a_separate_meal(self) -> None:
|
||||||
log_dishes([SlottedDish(dish=_dish("A"), slot=8)])
|
log_dishes([SlottedDish(dish=_dish("A"), slot=480)])
|
||||||
assert log_dishes([SlottedDish(dish=_dish("A"), slot=12)]) == ["A"]
|
assert log_dishes([SlottedDish(dish=_dish("A"), slot=720)]) == ["A"]
|
||||||
assert len(today_entries()) == 2
|
assert len(today_entries()) == 2
|
||||||
|
|
||||||
def test_duplicates_within_one_batch_land_once(self) -> None:
|
def test_duplicates_within_one_batch_land_once(self) -> None:
|
||||||
# today_entries() is read once, before the loop; without reflecting the
|
# today_entries() is read once, before the loop; without reflecting the
|
||||||
# writes locally both copies would be logged.
|
# writes locally both copies would be logged.
|
||||||
chosen = [
|
chosen = [
|
||||||
SlottedDish(dish=_dish("A"), slot=8),
|
SlottedDish(dish=_dish("A"), slot=480),
|
||||||
SlottedDish(dish=_dish("A"), slot=8),
|
SlottedDish(dish=_dish("A"), slot=480),
|
||||||
]
|
]
|
||||||
assert log_dishes(chosen) == ["A"]
|
assert log_dishes(chosen) == ["A"]
|
||||||
|
|
||||||
def test_matching_ignores_case_and_padding(self) -> None:
|
def test_matching_ignores_case_and_padding(self) -> None:
|
||||||
log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=8)])
|
log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=480)])
|
||||||
assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=8)]) == []
|
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:
|
def test_logging_nothing_is_not_an_error(self) -> None:
|
||||||
assert log_dishes([]) == []
|
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_import import dish_to_record
|
||||||
from diet_guard._kuchnia_parse import Dish, parse_menu
|
from diet_guard._kuchnia_parse import Dish, parse_menu
|
||||||
from diet_guard._kuchnia_spread import SlottedDish, assign_slots
|
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"
|
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)
|
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(
|
def _spread(
|
||||||
dishes: list[Dish], fill: dict[str, Any], case: dict[str, Any]
|
dishes: list[Dish], fill: dict[str, Any], case: dict[str, Any]
|
||||||
) -> list[SlottedDish]:
|
) -> list[SlottedDish]:
|
||||||
@ -168,7 +175,10 @@ def test_fill_plan_matches(
|
|||||||
fill = fixture["expected"]["fill"]
|
fill = fixture["expected"]["fill"]
|
||||||
assert fill["fill_plan"], "fixture carries no fill_plan cases"
|
assert fill["fill_plan"], "fixture carries no fill_plan cases"
|
||||||
for key, case in fill["fill_plan"].items():
|
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)
|
monkeypatch.setattr(_kuchnia_log, "today_entries", lambda t=today: t)
|
||||||
kept = _kuchnia_log.fill_plan(_spread(dishes, fill, case))
|
kept = _kuchnia_log.fill_plan(_spread(dishes, fill, case))
|
||||||
actual = [[item.dish.name, item.slot] for item in kept]
|
actual = [[item.dish.name, item.slot] for item in kept]
|
||||||
@ -185,7 +195,8 @@ def test_log_dishes_matches(
|
|||||||
fill = fixture["expected"]["fill"]
|
fill = fixture["expected"]["fill"]
|
||||||
for key, case in fill["log_dishes"].items():
|
for key, case in fill["log_dishes"].items():
|
||||||
recorder = MagicMock()
|
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)
|
monkeypatch.setattr(_kuchnia_log, "log_meal", recorder)
|
||||||
_kuchnia_log.log_dishes(_spread(dishes, fill, case))
|
_kuchnia_log.log_dishes(_spread(dishes, fill, case))
|
||||||
written = [[call.args[0], call.args[2]] for call in recorder.call_args_list]
|
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:
|
class TestAssignSlots:
|
||||||
def test_follows_the_providers_priority_not_payload_order(self) -> None:
|
def test_follows_the_providers_priority_not_payload_order(self) -> None:
|
||||||
dishes = [_dish(3), _dish(1), _dish(2)]
|
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.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:
|
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))
|
assigned = assign_slots([_dish(i) for i in range(1, 6)], (480, 720, 960, 1200))
|
||||||
assert [item.slot for item in assigned] == [8, 8, 12, 16, 20]
|
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:
|
def test_five_dishes_over_five_slots_map_one_to_one(self) -> None:
|
||||||
# kuhy's real schedule; the catering plan is also five meals.
|
# 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))
|
assigned = assign_slots(
|
||||||
assert [item.slot for item in assigned] == [8, 11, 14, 17, 20]
|
[_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("count", range(1, 9))
|
||||||
@pytest.mark.parametrize("span", range(2, 7))
|
@pytest.mark.parametrize("span", range(2, 7))
|
||||||
@ -170,8 +172,8 @@ class TestAssignSlots:
|
|||||||
# Two dishes sharing a priority must not reshuffle between runs: an
|
# Two dishes sharing a priority must not reshuffle between runs: an
|
||||||
# unstable order looks like a change and re-stamps every bank record.
|
# unstable order looks like a change and re-stamps every bank record.
|
||||||
dishes = [_dish(1, "b"), _dish(1, "a")]
|
dishes = [_dish(1, "b"), _dish(1, "a")]
|
||||||
first = [item.dish.name for item in assign_slots(dishes, (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], (8, 12))]
|
second = [item.dish.name for item in assign_slots(dishes[::-1], (480, 720))]
|
||||||
assert first == second
|
assert first == second
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@ -135,7 +135,7 @@ class TestTheGateAdvancesTheQueue:
|
|||||||
a dead letter: the form clears and the remaining dishes are stranded
|
a dead letter: the form clears and the remaining dishes are stranded
|
||||||
behind another button click, which is an unguarded network walk each.
|
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"),)
|
gate._delivery_pending = (_dish("Second course"),)
|
||||||
with (
|
with (
|
||||||
patch.object(_gatelock_mealflow, "log_meal"),
|
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)
|
_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, "consumption_band", return_value="on track"),
|
||||||
patch.object(_mcp_read, "due_slots", return_value=(12,)),
|
patch.object(_mcp_read, "due_slots", return_value=(720,)),
|
||||||
patch.object(_mcp_read, "logged_slots_today", return_value={8}),
|
patch.object(_mcp_read, "logged_slots_today", return_value={480}),
|
||||||
patch.object(_mcp_read, "current_slot", return_value=12),
|
patch.object(_mcp_read, "current_slot", return_value=12),
|
||||||
patch.object(_mcp, "now_local"),
|
patch.object(_mcp, "now_local"),
|
||||||
patch.object(_mcp_read, "slot_label", return_value="12:00"),
|
patch.object(_mcp_read, "slot_label", return_value="12:00"),
|
||||||
@ -46,7 +46,7 @@ class TestGetStatus:
|
|||||||
assert out["consumption_band"] == "on track"
|
assert out["consumption_band"] == "on track"
|
||||||
assert out["budget_initialized"] is True
|
assert out["budget_initialized"] is True
|
||||||
assert out["due_slots"] == ["12:00"]
|
assert out["due_slots"] == ["12:00"]
|
||||||
assert out["logged_slots"] == [8]
|
assert out["logged_slots"] == [480]
|
||||||
assert out["current_slot"] == 12
|
assert out["current_slot"] == 12
|
||||||
|
|
||||||
def test_no_budget_hides_band(self) -> None:
|
def test_no_budget_hides_band(self) -> None:
|
||||||
@ -92,7 +92,8 @@ class TestListToday:
|
|||||||
view = out["entries"][0]
|
view = out["entries"][0]
|
||||||
assert view["desc"] == "big mac"
|
assert view["desc"] == "big mac"
|
||||||
assert view["kcal"] == 550.0
|
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
|
assert "hmac" not in view
|
||||||
|
|
||||||
def test_empty(self) -> None:
|
def test_empty(self) -> None:
|
||||||
@ -104,14 +105,13 @@ class TestListToday:
|
|||||||
class TestGetSlots:
|
class TestGetSlots:
|
||||||
def test_lists_slots_and_current(self) -> None:
|
def test_lists_slots_and_current(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(_mcp_read, "day_slots", return_value=(8, 12, 16, 20)),
|
patch.object(_mcp_read, "day_slots", return_value=(435, 720, 960, 1200)),
|
||||||
patch.object(_mcp_read, "current_slot", return_value=16),
|
patch.object(_mcp_read, "current_slot", return_value=960),
|
||||||
patch.object(_mcp, "now_local"),
|
patch.object(_mcp, "now_local"),
|
||||||
patch.object(_mcp_read, "slot_label", side_effect=lambda s: f"{s:02d}:00"),
|
|
||||||
):
|
):
|
||||||
out = _mcp.get_slots()
|
out = _mcp.get_slots()
|
||||||
assert out["current_slot"] == 16
|
assert out["current_slot"] == 960
|
||||||
assert out["day_slots"][0] == {"hour": 8, "label": "08:00"}
|
assert out["day_slots"][0] == {"minute": 435, "label": "07:15"}
|
||||||
assert len(out["day_slots"]) == 4
|
assert len(out["day_slots"]) == 4
|
||||||
|
|
||||||
|
|
||||||
@ -141,7 +141,7 @@ class TestLogMealGate:
|
|||||||
)
|
)
|
||||||
record.assert_not_called()
|
record.assert_not_called()
|
||||||
assert out["preview"] is True
|
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["resolved"]["kcal"] == 250.0
|
||||||
assert out["confirm_required"] is True
|
assert out["confirm_required"] is True
|
||||||
|
|
||||||
@ -149,7 +149,7 @@ class TestLogMealGate:
|
|||||||
with (
|
with (
|
||||||
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
|
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
|
||||||
patch.object(_mcp, "ManualMacros") as manual,
|
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, "now_local"),
|
||||||
patch.object(_mcp, "record_meal") as record,
|
patch.object(_mcp, "record_meal") as record,
|
||||||
):
|
):
|
||||||
@ -157,7 +157,7 @@ class TestLogMealGate:
|
|||||||
manual.assert_not_called()
|
manual.assert_not_called()
|
||||||
record.assert_not_called()
|
record.assert_not_called()
|
||||||
assert out["preview"] is True
|
assert out["preview"] is True
|
||||||
assert out["target_slot"] == 8
|
assert out["target_slot"] == 480
|
||||||
|
|
||||||
def test_confirm_applies_signed(self) -> None:
|
def test_confirm_applies_signed(self) -> None:
|
||||||
entry = {"desc": "apple", "hmac": "sig"}
|
entry = {"desc": "apple", "hmac": "sig"}
|
||||||
@ -169,7 +169,7 @@ class TestLogMealGate:
|
|||||||
record.assert_called_once()
|
record.assert_called_once()
|
||||||
assert out["applied"] is True
|
assert out["applied"] is True
|
||||||
assert out["signed"] is True
|
assert out["signed"] is True
|
||||||
assert out["target_slot"] == 8
|
assert out["target_slot"] == 480
|
||||||
|
|
||||||
def test_confirm_applies_unsigned(self) -> None:
|
def test_confirm_applies_unsigned(self) -> None:
|
||||||
entry = {"desc": "apple"}
|
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."""
|
"""Encoding then decoding preserves the schedule and the stamp."""
|
||||||
entry = store.ScheduleEntry(
|
entry = store.ScheduleEntry(
|
||||||
effective_from="2026-08-16",
|
effective_from="2026-08-16",
|
||||||
schedule=MealSchedule(8, 20, 5),
|
schedule=MealSchedule(480, 1200, 5),
|
||||||
edited_at="2026-08-16T12:00:00+00:00",
|
edited_at="2026-08-16T12:00:00+00:00",
|
||||||
)
|
)
|
||||||
assert store.entry_from_json("2026-08-16", store.entry_to_json(entry)) == entry
|
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:
|
def test_normalizes_on_the_way_in(self) -> None:
|
||||||
"""A peer's out-of-range schedule is clamped, not trusted verbatim."""
|
"""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})
|
entry = store.entry_from_json("2026-08-16", {"f": 8, "l": 20, "n": 99})
|
||||||
assert entry is not None
|
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:
|
def test_missing_timestamp_falls_back_to_the_epoch(self) -> None:
|
||||||
"""An entry with no stamp still parses, losing only its ordering."""
|
"""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:
|
def test_round_trips_and_sorts(self) -> None:
|
||||||
"""Entries come back ascending regardless of insertion order."""
|
"""Entries come back ascending regardless of insertion order."""
|
||||||
entries = (
|
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"),
|
store.ScheduleEntry("2026-01-01", DEFAULT_SCHEDULE, "t0"),
|
||||||
)
|
)
|
||||||
parsed = store.history_from_json(store.history_to_json(entries))
|
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:
|
def test_defaults_when_the_history_is_silent(self) -> None:
|
||||||
"""A day before any entry uses the default, not the newest entry."""
|
"""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
|
assert store.schedule_for_day(entries, "2026-08-15") == DEFAULT_SCHEDULE
|
||||||
|
|
||||||
def test_uses_the_newest_applicable_entry(self) -> None:
|
def test_uses_the_newest_applicable_entry(self) -> None:
|
||||||
"""The latest entry effective on or before the day wins."""
|
"""The latest entry effective on or before the day wins."""
|
||||||
entries = (
|
entries = (
|
||||||
store.ScheduleEntry("2026-01-01", MealSchedule(8, 20, 4), "t0"),
|
store.ScheduleEntry("2026-01-01", MealSchedule(480, 1200, 4), "t0"),
|
||||||
store.ScheduleEntry("2026-08-16", MealSchedule(8, 20, 5), "t1"),
|
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:
|
class TestUpsert:
|
||||||
@ -108,16 +128,16 @@ class TestUpsert:
|
|||||||
|
|
||||||
def test_appends_a_new_day(self) -> None:
|
def test_appends_a_new_day(self) -> None:
|
||||||
"""An edit on a fresh day adds an entry."""
|
"""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 len(entries) == 1
|
||||||
assert entries[0].effective_from == "2026-08-16"
|
assert entries[0].effective_from == "2026-08-16"
|
||||||
|
|
||||||
def test_replaces_a_same_day_re_edit(self) -> None:
|
def test_replaces_a_same_day_re_edit(self) -> None:
|
||||||
"""Editing twice in one day leaves one entry, not two."""
|
"""Editing twice in one day leaves one entry, not two."""
|
||||||
first = store.upsert((), MealSchedule(8, 20, 5), _at("2026-08-16"))
|
first = store.upsert((), MealSchedule(480, 1200, 5), _at("2026-08-16"))
|
||||||
second = store.upsert(first, MealSchedule(9, 21, 3), _at("2026-08-16"))
|
second = store.upsert(first, MealSchedule(540, 1260, 3), _at("2026-08-16"))
|
||||||
assert len(second) == 1
|
assert len(second) == 1
|
||||||
assert second[0].schedule == MealSchedule(9, 21, 3)
|
assert second[0].schedule == MealSchedule(540, 1260, 3)
|
||||||
|
|
||||||
|
|
||||||
class TestSeedDefault:
|
class TestSeedDefault:
|
||||||
@ -144,10 +164,10 @@ class TestPersistence:
|
|||||||
|
|
||||||
def test_round_trips_through_disk(self) -> None:
|
def test_round_trips_through_disk(self) -> None:
|
||||||
"""What is written is what is read back."""
|
"""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(
|
assert store.schedule_for_day(
|
||||||
store.load_entries(), "2026-08-16"
|
store.load_entries(), "2026-08-16"
|
||||||
) == MealSchedule(8, 20, 5)
|
) == MealSchedule(480, 1200, 5)
|
||||||
|
|
||||||
def test_recording_grandfathers_earlier_days(self) -> None:
|
def test_recording_grandfathers_earlier_days(self) -> None:
|
||||||
"""Switching to five meals leaves past days on the four-meal schedule.
|
"""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
|
past day would adopt the schedule chosen today and look like it had
|
||||||
missed a checkpoint.
|
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()
|
entries = store.load_entries()
|
||||||
assert store.schedule_for_day(entries, "2020-01-01") == DEFAULT_SCHEDULE
|
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:
|
def test_corrupt_file_degrades_to_the_default(self) -> None:
|
||||||
"""Unparsable JSON is treated as "no history", never a crash."""
|
"""Unparsable JSON is treated as "no history", never a crash."""
|
||||||
@ -189,8 +211,8 @@ class TestPersistence:
|
|||||||
|
|
||||||
def test_record_defaults_to_now(self) -> None:
|
def test_record_defaults_to_now(self) -> None:
|
||||||
"""Omitting ``when`` stamps the edit with the current time."""
|
"""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()
|
today = datetime.now(tz=UTC).astimezone().date().isoformat()
|
||||||
assert store.schedule_for_day(store.load_entries(), today) == MealSchedule(
|
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:
|
def test_log_and_total(self) -> None:
|
||||||
"""A logged meal counts toward the day's calories."""
|
"""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
|
assert today_total_kcal() == 150.0
|
||||||
|
|
||||||
def test_entry_carries_signature(self) -> None:
|
def test_entry_carries_signature(self) -> None:
|
||||||
"""With a key present, the stored entry is signed."""
|
"""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
|
assert "hmac" in entry
|
||||||
|
|
||||||
def test_unsigned_when_no_key(self) -> None:
|
def test_unsigned_when_no_key(self) -> None:
|
||||||
"""With no key, the entry is written unsigned and still read back."""
|
"""With no key, the entry is written unsigned and still read back."""
|
||||||
with patch.object(_state, "compute_entry_hmac", return_value=None):
|
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 "hmac" not in _raw()[next(iter(_raw()))][0]
|
||||||
assert today_total_kcal() == 150.0
|
assert today_total_kcal() == 150.0
|
||||||
|
|
||||||
def test_macros_sum(self) -> None:
|
def test_macros_sum(self) -> None:
|
||||||
"""today_total_macros sums protein/carbs/fat across entries."""
|
"""today_total_macros sums protein/carbs/fat across entries."""
|
||||||
log_meal("eggs", _nut(140, protein=12, carbs=1, fat=10), slot=8)
|
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=12)
|
log_meal("rice", _nut(200, protein=4, carbs=44, fat=1), slot=720)
|
||||||
assert today_total_macros() == (16.0, 45.0, 11.0)
|
assert today_total_macros() == (16.0, 45.0, 11.0)
|
||||||
|
|
||||||
def test_slotless_entry_counts_calories_only(self) -> None:
|
def test_slotless_entry_counts_calories_only(self) -> None:
|
||||||
@ -141,12 +141,12 @@ class TestVerification:
|
|||||||
|
|
||||||
def test_valid_entry_kept(self) -> None:
|
def test_valid_entry_kept(self) -> None:
|
||||||
"""A correctly signed entry survives verification."""
|
"""A correctly signed entry survives verification."""
|
||||||
log_meal("toast", _nut(150), slot=8)
|
log_meal("toast", _nut(150), slot=480)
|
||||||
assert today_entries()
|
assert today_entries()
|
||||||
|
|
||||||
def test_tampered_entry_dropped(self) -> None:
|
def test_tampered_entry_dropped(self) -> None:
|
||||||
"""An edited calorie value invalidates the signature and is dropped."""
|
"""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()
|
raw = _raw()
|
||||||
day = next(iter(raw))
|
day = next(iter(raw))
|
||||||
raw[day][0]["kcal"] = 999
|
raw[day][0]["kcal"] = 999
|
||||||
@ -184,19 +184,19 @@ class TestIdAndComponents:
|
|||||||
|
|
||||||
def test_entry_has_id(self) -> None:
|
def test_entry_has_id(self) -> None:
|
||||||
"""Every logged entry carries a UUID id."""
|
"""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 isinstance(entry["id"], str)
|
||||||
assert entry["id"]
|
assert entry["id"]
|
||||||
|
|
||||||
def test_ids_are_unique(self) -> None:
|
def test_ids_are_unique(self) -> None:
|
||||||
"""Two entries never collide on id."""
|
"""Two entries never collide on id."""
|
||||||
first = log_meal("a", _nut(1), slot=8)
|
first = log_meal("a", _nut(1), slot=480)
|
||||||
second = log_meal("b", _nut(1), slot=12)
|
second = log_meal("b", _nut(1), slot=720)
|
||||||
assert first["id"] != second["id"]
|
assert first["id"] != second["id"]
|
||||||
|
|
||||||
def test_components_omitted_by_default(self) -> None:
|
def test_components_omitted_by_default(self) -> None:
|
||||||
"""A single-food entry carries no components field."""
|
"""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
|
assert "components" not in entry
|
||||||
|
|
||||||
def test_components_carried_through(self) -> None:
|
def test_components_carried_through(self) -> None:
|
||||||
@ -211,7 +211,7 @@ class TestIdAndComponents:
|
|||||||
"grams": 100.0,
|
"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
|
assert entry["components"] == parts
|
||||||
|
|
||||||
|
|
||||||
@ -221,7 +221,7 @@ class TestFutureLogging:
|
|||||||
def test_buckets_under_the_future_date(self) -> None:
|
def test_buckets_under_the_future_date(self) -> None:
|
||||||
"""A future ``when`` places the entry under that date, not today."""
|
"""A future ``when`` places the entry under that date, not today."""
|
||||||
future = now_local() + timedelta(days=1)
|
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()
|
future_day = future.date().isoformat()
|
||||||
assert today_total_kcal() == 0.0
|
assert today_total_kcal() == 0.0
|
||||||
assert load_log()[future_day][0]["desc"] == "future meal"
|
assert load_log()[future_day][0]["desc"] == "future meal"
|
||||||
@ -231,5 +231,5 @@ class TestFutureLogging:
|
|||||||
"""A ``when`` before now raises ValueError and writes nothing."""
|
"""A ``when`` before now raises ValueError and writes nothing."""
|
||||||
past = now_local() - timedelta(days=1)
|
past = now_local() - timedelta(days=1)
|
||||||
with pytest.raises(ValueError, match="in the past"):
|
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() == {}
|
assert load_log() == {}
|
||||||
|
|||||||
@ -52,8 +52,8 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_leaves_earlier_entries(self) -> None:
|
def test_undo_leaves_earlier_entries(self) -> None:
|
||||||
"""Undo tombstones only the last entry when others remain."""
|
"""Undo tombstones only the last entry when others remain."""
|
||||||
log_meal("a", _nut(100), slot=8)
|
log_meal("a", _nut(100), slot=480)
|
||||||
log_meal("b", _nut(200), slot=12)
|
log_meal("b", _nut(200), slot=720)
|
||||||
removed = undo_last_today()
|
removed = undo_last_today()
|
||||||
assert removed is not None
|
assert removed is not None
|
||||||
assert removed["desc"] == "b"
|
assert removed["desc"] == "b"
|
||||||
@ -61,7 +61,7 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_tombstones_in_place(self) -> None:
|
def test_undo_tombstones_in_place(self) -> None:
|
||||||
"""Undoing the only entry keeps it on disk, marked deleted."""
|
"""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()
|
undo_last_today()
|
||||||
raw = _raw()
|
raw = _raw()
|
||||||
day = next(iter(raw))
|
day = next(iter(raw))
|
||||||
@ -70,7 +70,7 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_tombstone_excluded_from_reads(self) -> None:
|
def test_undo_tombstone_excluded_from_reads(self) -> None:
|
||||||
"""A tombstoned entry no longer counts toward totals or slots."""
|
"""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()
|
undo_last_today()
|
||||||
assert today_total_kcal() == 0.0
|
assert today_total_kcal() == 0.0
|
||||||
assert today_entries() == []
|
assert today_entries() == []
|
||||||
@ -78,7 +78,7 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_re_signs_the_tombstone(self) -> None:
|
def test_undo_re_signs_the_tombstone(self) -> None:
|
||||||
"""The mutated (tombstoned) entry still carries a valid signature."""
|
"""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()
|
undo_last_today()
|
||||||
raw = _raw()
|
raw = _raw()
|
||||||
day = next(iter(raw))
|
day = next(iter(raw))
|
||||||
@ -86,7 +86,7 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_unsigned_when_no_key(self) -> None:
|
def test_undo_unsigned_when_no_key(self) -> None:
|
||||||
"""Re-signing a tombstone with no key available leaves it unsigned."""
|
"""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):
|
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
|
||||||
undo_last_today()
|
undo_last_today()
|
||||||
raw = _raw()
|
raw = _raw()
|
||||||
@ -95,8 +95,8 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_skips_already_tombstoned(self) -> None:
|
def test_undo_skips_already_tombstoned(self) -> None:
|
||||||
"""Undoing twice tombstones the prior entry, not the same one again."""
|
"""Undoing twice tombstones the prior entry, not the same one again."""
|
||||||
log_meal("a", _nut(100), slot=8)
|
log_meal("a", _nut(100), slot=480)
|
||||||
log_meal("b", _nut(200), slot=12)
|
log_meal("b", _nut(200), slot=720)
|
||||||
undo_last_today()
|
undo_last_today()
|
||||||
second = undo_last_today()
|
second = undo_last_today()
|
||||||
assert second is not None
|
assert second is not None
|
||||||
@ -104,7 +104,7 @@ class TestUndo:
|
|||||||
|
|
||||||
def test_undo_nothing_left_once_all_tombstoned(self) -> None:
|
def test_undo_nothing_left_once_all_tombstoned(self) -> None:
|
||||||
"""Once every entry today is tombstoned, undo returns 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()
|
undo_last_today()
|
||||||
assert undo_last_today() is None
|
assert undo_last_today() is None
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ class TestLoadLogSkipsTombstones:
|
|||||||
|
|
||||||
def test_day_with_only_a_tombstone_is_omitted(self) -> None:
|
def test_day_with_only_a_tombstone_is_omitted(self) -> None:
|
||||||
"""A day whose sole entry is tombstoned is dropped entirely."""
|
"""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()
|
undo_last_today()
|
||||||
assert load_log() == {}
|
assert load_log() == {}
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ class TestRawLogAccess:
|
|||||||
|
|
||||||
def test_read_raw_log_includes_tombstones(self) -> None:
|
def test_read_raw_log_includes_tombstones(self) -> None:
|
||||||
"""Unlike load_log, read_raw_log keeps a tombstoned entry."""
|
"""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()
|
undo_last_today()
|
||||||
raw = read_raw_log()
|
raw = read_raw_log()
|
||||||
day = next(iter(raw))
|
day = next(iter(raw))
|
||||||
@ -163,7 +163,7 @@ class TestResignEntry:
|
|||||||
|
|
||||||
def test_strips_and_recomputes_signature(self) -> None:
|
def test_strips_and_recomputes_signature(self) -> None:
|
||||||
"""A re-signed entry's hmac changes but verifies against the key."""
|
"""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)
|
tampered = dict(entry, kcal=999.0)
|
||||||
resigned = resign_entry(tampered)
|
resigned = resign_entry(tampered)
|
||||||
assert resigned["hmac"] != entry["hmac"]
|
assert resigned["hmac"] != entry["hmac"]
|
||||||
@ -171,9 +171,38 @@ class TestResignEntry:
|
|||||||
with patch.object(_state_today, "_today", return_value="2026-06-22"):
|
with patch.object(_state_today, "_today", return_value="2026-06-22"):
|
||||||
assert today_entries() == [resigned]
|
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:
|
def test_no_op_signature_wise_when_no_key_available(self) -> None:
|
||||||
"""Without an HMAC key, resign_entry produces no hmac field."""
|
"""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):
|
with patch.object(_state_sync, "compute_entry_hmac", return_value=None):
|
||||||
resigned = resign_entry(entry)
|
resigned = resign_entry(entry)
|
||||||
assert "hmac" not in resigned
|
assert "hmac" not in resigned
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import pytest
|
|||||||
from diet_guard import _state
|
from diet_guard import _state
|
||||||
from diet_guard._budget import BudgetNotInitializedError, write_budget
|
from diet_guard._budget import BudgetNotInitializedError, write_budget
|
||||||
from diet_guard._estimator import Nutrition
|
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 (
|
from diet_guard._state import (
|
||||||
log_meal,
|
log_meal,
|
||||||
)
|
)
|
||||||
@ -40,19 +42,36 @@ class TestLoggedSlots:
|
|||||||
"""Which slots today's log has satisfied."""
|
"""Which slots today's log has satisfied."""
|
||||||
|
|
||||||
def test_int_slots_counted(self) -> None:
|
def test_int_slots_counted(self) -> None:
|
||||||
"""Integer slot tags are reported."""
|
"""Whole-hour slot minutes are reported as the slots they are."""
|
||||||
log_meal("a", _nut(1), slot=8)
|
log_meal("a", _nut(1), slot=480)
|
||||||
log_meal("b", _nut(1), slot=12)
|
log_meal("b", _nut(1), slot=720)
|
||||||
assert logged_slots_today() == {8, 12}
|
assert logged_slots_today() == {480, 720}
|
||||||
|
|
||||||
def test_bool_slot_excluded(self) -> None:
|
def test_bool_slot_excluded(self) -> None:
|
||||||
"""A bool masquerading as a slot is ignored."""
|
"""A bool masquerading as a slot is ignored."""
|
||||||
log_meal("a", _nut(1), slot=8)
|
log_meal("a", _nut(1), slot=480)
|
||||||
raw = _raw()
|
raw = _raw()
|
||||||
day = next(iter(raw))
|
day = next(iter(raw))
|
||||||
raw[day].append({"kcal": 1, "slot": True})
|
raw[day].append({"kcal": 1, "slot": True})
|
||||||
_state.FOOD_LOG_FILE.write_text(json.dumps(raw), encoding="utf-8")
|
_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:
|
class TestBudgetViews:
|
||||||
@ -66,23 +85,23 @@ class TestBudgetViews:
|
|||||||
def test_remaining_value(self) -> None:
|
def test_remaining_value(self) -> None:
|
||||||
"""Remaining is budget minus today's total."""
|
"""Remaining is budget minus today's total."""
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
log_meal("lunch", _nut(500), slot=12)
|
log_meal("lunch", _nut(500), slot=720)
|
||||||
assert remaining_budget() == 1500.0
|
assert remaining_budget() == 1500.0
|
||||||
|
|
||||||
def test_band_on_track(self) -> None:
|
def test_band_on_track(self) -> None:
|
||||||
"""Well under the warn fraction is 'on track'."""
|
"""Well under the warn fraction is 'on track'."""
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
log_meal("a", _nut(500), slot=8)
|
log_meal("a", _nut(500), slot=480)
|
||||||
assert consumption_band() == "on track"
|
assert consumption_band() == "on track"
|
||||||
|
|
||||||
def test_band_approaching(self) -> None:
|
def test_band_approaching(self) -> None:
|
||||||
"""At or above the warn fraction but under budget is 'approaching limit'."""
|
"""At or above the warn fraction but under budget is 'approaching limit'."""
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
log_meal("a", _nut(1700), slot=8)
|
log_meal("a", _nut(1700), slot=480)
|
||||||
assert consumption_band() == "approaching limit"
|
assert consumption_band() == "approaching limit"
|
||||||
|
|
||||||
def test_band_over(self) -> None:
|
def test_band_over(self) -> None:
|
||||||
"""At or above budget is 'OVER BUDGET'."""
|
"""At or above budget is 'OVER BUDGET'."""
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
log_meal("a", _nut(2100), slot=8)
|
log_meal("a", _nut(2100), slot=480)
|
||||||
assert consumption_band() == "OVER BUDGET"
|
assert consumption_band() == "OVER BUDGET"
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class TestRunSync:
|
|||||||
|
|
||||||
def test_pushes_local_log_when_no_other_devices_have_synced(self) -> None:
|
def test_pushes_local_log_when_no_other_devices_have_synced(self) -> None:
|
||||||
_write_token()
|
_write_token()
|
||||||
log_meal("oatmeal", _nutrition(), slot=8)
|
log_meal("oatmeal", _nutrition(), slot=480)
|
||||||
client = _mock_client(devices=())
|
client = _mock_client(devices=())
|
||||||
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
||||||
merged = _sync.run_sync()
|
merged = _sync.run_sync()
|
||||||
@ -192,7 +192,7 @@ class TestRunSync:
|
|||||||
|
|
||||||
def test_rebuilds_the_food_bank_after_merge(self) -> None:
|
def test_rebuilds_the_food_bank_after_merge(self) -> None:
|
||||||
_write_token()
|
_write_token()
|
||||||
log_meal("oatmeal", _nutrition(), slot=8)
|
log_meal("oatmeal", _nutrition(), slot=480)
|
||||||
client = _mock_client(devices=())
|
client = _mock_client(devices=())
|
||||||
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
||||||
_sync.run_sync()
|
_sync.run_sync()
|
||||||
|
|||||||
@ -145,11 +145,11 @@ class TestSyncBudget:
|
|||||||
"""
|
"""
|
||||||
_write_token()
|
_write_token()
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
record_schedule_change(MealSchedule(8, 20, 5))
|
record_schedule_change(MealSchedule(480, 1200, 5))
|
||||||
client = _mock_client(devices=())
|
client = _mock_client(devices=())
|
||||||
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
||||||
_sync.run_sync()
|
_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:
|
def test_malformed_remote_budget_is_skipped(self) -> None:
|
||||||
"""A corrupt remote budget.json is skipped, not a crash."""
|
"""A corrupt remote budget.json is skipped, not a crash."""
|
||||||
|
|||||||
@ -166,6 +166,20 @@ class TestEntryRecordRoundTrip:
|
|||||||
entry = _entry(id="x")
|
entry = _entry(id="x")
|
||||||
assert record_to_entry(entry_to_record(entry)) == entry
|
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:
|
def test_round_trip_of_a_deleted_entry_preserves_the_tombstone(self) -> None:
|
||||||
entry = _entry(id="x", deleted=True)
|
entry = _entry(id="x", deleted=True)
|
||||||
assert record_to_entry(entry_to_record(entry))["deleted"] is 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:
|
def test_one_field_per_entry(self) -> None:
|
||||||
"""Each entry becomes its own ``sched:<date>`` field."""
|
"""Each entry becomes its own ``sched:<date>`` field."""
|
||||||
fields = schedule_fields(
|
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"}
|
assert set(fields) == {f"{SCHEDULE_FIELD_PREFIX}2026-08-16"}
|
||||||
value, _ = 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:
|
def test_hlc_is_deterministic(self) -> None:
|
||||||
"""Re-syncing an unchanged history is a no-op, not a fresh write."""
|
"""Re-syncing an unchanged history is a no-op, not a fresh write."""
|
||||||
entry = _entry(
|
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]
|
first = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
|
||||||
second = 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:
|
def test_unparsable_timestamp_falls_back_to_the_epoch(self) -> None:
|
||||||
"""A malformed stamp still yields a clock, so the entry still merges."""
|
"""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]
|
hlc = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
|
||||||
assert hlc.wall_time_ms == 0
|
assert hlc.wall_time_ms == 0
|
||||||
|
|
||||||
@ -74,12 +80,30 @@ class TestLogToScheduleHistory:
|
|||||||
def test_round_trips(self) -> None:
|
def test_round_trips(self) -> None:
|
||||||
"""What one device pushes is what the other reads back."""
|
"""What one device pushes is what the other reads back."""
|
||||||
entries = (
|
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))
|
back = log_to_schedule_history(budget_to_log(_RECORD, (), entries))
|
||||||
assert len(back) == 1
|
assert len(back) == 1
|
||||||
assert back[0].effective_from == "2026-08-16"
|
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:
|
def test_normalizes_a_peers_out_of_range_schedule(self) -> None:
|
||||||
"""A future peer cannot make this device derive different slots."""
|
"""A future peer cannot make this device derive different slots."""
|
||||||
@ -90,7 +114,7 @@ class TestLogToScheduleHistory:
|
|||||||
{"f": 8, "l": 20, "n": 99},
|
{"f": 8, "l": 20, "n": 99},
|
||||||
hlc,
|
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:
|
def test_skips_malformed_values(self) -> None:
|
||||||
"""One bad field from a peer cannot take out the whole history."""
|
"""One bad field from a peer cannot take out the whole history."""
|
||||||
@ -118,7 +142,9 @@ class TestCrossDeviceMerge:
|
|||||||
"""
|
"""
|
||||||
entries = (
|
entries = (
|
||||||
_entry("1970-01-01", DEFAULT_SCHEDULE, "1970-01-01T00:00:00+00:00"),
|
_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)
|
ours = budget_to_log(_RECORD, (), entries)
|
||||||
peer = budget_to_log(
|
peer = budget_to_log(
|
||||||
@ -130,7 +156,7 @@ class TestCrossDeviceMerge:
|
|||||||
|
|
||||||
assert [entry.schedule for entry in back] == [
|
assert [entry.schedule for entry in back] == [
|
||||||
DEFAULT_SCHEDULE,
|
DEFAULT_SCHEDULE,
|
||||||
MealSchedule(8, 20, 5),
|
MealSchedule(480, 1200, 5),
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_the_newer_edit_wins_per_day(self) -> None:
|
def test_the_newer_edit_wins_per_day(self) -> None:
|
||||||
@ -140,7 +166,9 @@ class TestCrossDeviceMerge:
|
|||||||
(),
|
(),
|
||||||
(
|
(
|
||||||
_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",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -149,10 +177,12 @@ class TestCrossDeviceMerge:
|
|||||||
(),
|
(),
|
||||||
(
|
(
|
||||||
_entry(
|
_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)
|
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._
|
_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
|
Every slot is a **minute of day**, an int in `0..1439`. The user sets a first
|
||||||
between them; the intermediate checkpoints are derived by even division.
|
meal and a last meal (any `HH:MM`) and how many meals fall between them; the
|
||||||
`MealSchedule(8, 20, 5)` → `08:00, 11:00, 14:00, 17:00, 20:00`. The default
|
intermediate checkpoints are derived by even division and rounded to the
|
||||||
`MealSchedule(8, 20, 4)` reproduces the hours that used to be hardcoded, so an
|
15-minute grid. `MealSchedule(480, 1200, 5)` → `08:00, 11:00, 14:00, 17:00,
|
||||||
existing install sees no change until it is edited.
|
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
|
## The rules, each load-bearing
|
||||||
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
|
- **Integer arithmetic only** — see the `Do NOT` entry in `CLAUDE.md`.
|
||||||
cross-language divergence before it reaches a device.
|
Python's `round` is banker's and Dart's is half-away-from-zero, and
|
||||||
- **Slots stay whole hours.** Changing that changes the on-disk log's `slot`
|
Python's `//` floors where Dart's `~/` truncates, so every division runs on
|
||||||
tag and the sync wire format, on both devices.
|
a value `normalized()` has already pulled into the day. The shared vectors
|
||||||
- **Counts are clamped to the window width** (`last - first + 1`), because a
|
in `tests/fixtures/meal_schedule_vectors.json` (regenerated by
|
||||||
narrow window would otherwise round two meals onto the same hour, and slot
|
`scripts/build_meal_schedule_fixture.py`) are asserted by
|
||||||
hours are set members, dict keys *and* notification ids — a duplicate
|
`test_meal_schedule_vectors.py` *and* `meal_schedule_vectors_test.dart`;
|
||||||
silently drops a checkpoint. The two edit surfaces reject such input with a
|
that is what catches a cross-language divergence before it reaches a device.
|
||||||
message; `normalized()` clamps it, which is the defence against a peer's
|
- **Interior slots round to the 15-minute grid; the endpoints never do.**
|
||||||
corrupt sync data rather than the input contract.
|
With `span = last - first` and `d = count - 1`, interior slot *i* is
|
||||||
- **The enforcement cutoff is `last + 2h`, clamped to midnight.** It is a
|
`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.
|
grace period for a late dinner, deliberately independent of meal count.
|
||||||
Unclamped, a 23:00 last meal gives 25 and `hour < cutoff` is vacuously
|
Unclamped, a 23:00 last meal gives 1500 and `minute < cutoff` is vacuously
|
||||||
true — the window never closes.
|
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
|
`_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
|
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
|
user out; making it required lets mypy and `flutter analyze` enumerate the
|
||||||
sites. Callers resolve it at the impure edge through
|
sites. Callers resolve it at the impure edge through
|
||||||
`_meal_schedule_store.current_schedule()` / `MealScheduleService.current`.
|
`_meal_schedule_store.current_schedule()` / `MealScheduleService.current`.
|
||||||
|
|
||||||
|
## History and editing
|
||||||
|
|
||||||
The history is forward-only (`_meal_schedule_store.py` /
|
The history is forward-only (`_meal_schedule_store.py` /
|
||||||
`meal_schedule_history.dart`), so switching from four meals to five does not
|
`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
|
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
|
Editable on both surfaces: the app's Settings screen
|
||||||
(`settings_meal_schedule.dart`) and the gate's History tab
|
(`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
|
`diet-guard-gate.timer` fires every 15 minutes (`*:00/15:00`), so it lands on
|
||||||
id space, not the current schedule's slots — otherwise a schedule change
|
every grid slot exactly; an off-grid first/last meal is caught by the next
|
||||||
orphans the ids it no longer contains and they nag forever.
|
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
|
# site-packages (the systemd service runs /usr/bin/python directly, not
|
||||||
# a venv, so the package must live where that interpreter can find it —
|
# a venv, so the package must live where that interpreter can find it —
|
||||||
# see CLAUDE.md's "Production dependency installation" section)
|
# 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
|
# 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
|
# (the sync itself stays unconfigured -- and a no-op -- until you create
|
||||||
# a sync token; see the reminder this step prints)
|
# 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"
|
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now diet-guard-gate.timer
|
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 ---------------------------------------
|
# 4. Retire the old periodic sync timer ---------------------------------------
|
||||||
# Sync is event-driven now (before a lock, after a meal is logged -- see
|
# Sync is event-driven now (before a lock, after a meal is logged -- see
|
||||||
@ -96,6 +96,6 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== Installation complete ==="
|
echo "=== Installation complete ==="
|
||||||
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
|
echo "The gate checks every ~15 min and locks until you log a meal once a meal"
|
||||||
echo "once you have gone 5h without logging."
|
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"
|
echo "Test the lock now (safe, closeable): python -m diet_guard gate --demo"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user