diet-guard/diet_guard/tests/test_gatelock_scheduleedit.py
Krzysztof kuhy Rudnicki c0564c05a2 Let the PC gate set the eating window and meal count
The second edit surface, so the schedule is editable from either device the
way the budget already is. A row on the History tab beside the budget row,
with the same Edit/Save toggle: first hour, last hour, meals per day, and the
derived checkpoint times underneath so the effect of a change is visible
before it is saved.

Validated rather than clamped. MealSchedule.normalized() would happily turn
"20 to 8" into a two-meal day ending at 09:00, which is a sensible defence
against a peer's corrupt sync data and a terrible response to a typo at the
keyboard. Each rejection says what is wrong and leaves the row in edit mode so
the value can be corrected instead of silently becoming a different schedule.
The narrow-window case has its own message, because that one is genuinely
non-obvious: six meals between 08:00 and 11:00 would round two checkpoints
onto the same hour, and a duplicate slot silently drops one.

Three structural notes:

- The row's widgets are their own module. _gatelock_calendar_widgets.py was at
  216 of the 250-line cap and is now 233; adding the row inline would have
  breached it.
- The five new StringVars are grouped in a ScheduleVars dataclass rather than
  flattened onto CalendarVars, which pylint flagged at 11 attributes. Grouping
  two unrelated concerns separately is the better shape anyway.
- _gate_fixtures.py's _GATE_TK_MODULES is a hand-listed patch target, so both
  new modules are registered there. Left out, they build real tk widgets under
  a fake parent -- which is how it failed the first time, loudly, rather than
  silently opening a window.

785 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 15:02:35 +02:00

146 lines
5.4 KiB
Python

"""Tests for the gate History tab's meal-schedule row.
Covers :mod:`._gatelock_scheduleedit`. The functional fake ``tk`` widgets and
the ``gate`` fixture live in ``conftest.py``, shared with the other gate
tests.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._gatelock_scheduleedit import schedule_summary
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
if TYPE_CHECKING:
from diet_guard._gatelock import MealGate
def _type(gate: MealGate, first: str, last: str, count: str) -> None:
"""Fill the three schedule entries as the user would."""
gate._cal_vars.schedule.first.set(first)
gate._cal_vars.schedule.last.set(last)
gate._cal_vars.schedule.count.set(count)
class TestScheduleSummary:
"""The derived-times label."""
def test_lists_every_checkpoint(self) -> None:
"""The label spells out the hours the schedule derives."""
assert schedule_summary(MealSchedule(8, 20, 5)) == (
"08:00 11:00 14:00 17:00 20:00"
)
class TestEditToggle:
"""The Edit/Save button's two states."""
def test_first_click_unlocks_without_saving(self, gate: MealGate) -> None:
"""Clicking Edit opens the fields but persists nothing yet."""
gate._on_edit_or_save_schedule()
assert gate._cal_editing_schedule
assert current_schedule() == DEFAULT_SCHEDULE
def test_second_click_persists_and_relocks(self, gate: MealGate) -> None:
"""Clicking Save validates, writes, and returns to read-only."""
gate._on_edit_or_save_schedule()
_type(gate, "8", "20", "5")
gate._on_edit_or_save_schedule()
assert not gate._cal_editing_schedule
assert current_schedule() == MealSchedule(8, 20, 5)
assert gate._cal_vars.schedule.status.get() == "Saved."
def test_a_failed_save_leaves_editing_open(self, gate: MealGate) -> None:
"""A bad value can be corrected rather than silently discarded."""
gate._on_edit_or_save_schedule()
_type(gate, "20", "8", "4")
gate._on_edit_or_save_schedule()
assert gate._cal_editing_schedule
assert current_schedule() == DEFAULT_SCHEDULE
class TestValidation:
"""Rejected input, with the reason shown rather than silently clamped."""
def test_rejects_non_numeric(self, gate: MealGate) -> None:
"""Letters in an hour field are refused, not coerced."""
_type(gate, "eight", "20", "4")
assert not gate._save_schedule_entry()
assert "whole hours" in gate._cal_vars.schedule.status.get()
def test_rejects_a_backwards_window(self, gate: MealGate) -> None:
"""The last meal must come after the first."""
_type(gate, "20", "8", "4")
assert not gate._save_schedule_entry()
assert "before the last" in gate._cal_vars.schedule.status.get()
def test_rejects_an_out_of_range_hour(self, gate: MealGate) -> None:
"""Hours outside 0-23 are refused."""
_type(gate, "8", "25", "4")
assert not gate._save_schedule_entry()
assert "0-23" in gate._cal_vars.schedule.status.get()
def test_rejects_too_few_meals(self, gate: MealGate) -> None:
"""One meal a day is below the supported range."""
_type(gate, "8", "20", "1")
assert not gate._save_schedule_entry()
assert "Meals per day" in gate._cal_vars.schedule.status.get()
def test_rejects_too_many_meals(self, gate: MealGate) -> None:
"""More than the cap is refused."""
_type(gate, "8", "20", "9")
assert not gate._save_schedule_entry()
assert "Meals per day" in gate._cal_vars.schedule.status.get()
def test_rejects_more_meals_than_the_window_holds(self, gate: MealGate) -> None:
"""A narrow window cannot hold six distinct whole-hour checkpoints.
Without this the derivation would round two meals onto the same hour,
and a duplicate slot silently drops a checkpoint.
"""
_type(gate, "8", "11", "6")
assert not gate._save_schedule_entry()
assert "Too many meals" in gate._cal_vars.schedule.status.get()
class TestDisplay:
"""Showing the stored schedule in the row."""
def test_shows_the_stored_schedule(self, gate: MealGate) -> None:
"""The fields and the derived-times label reflect what is stored."""
gate._show_schedule(MealSchedule(7, 21, 3))
assert gate._cal_vars.schedule.first.get() == "7"
assert gate._cal_vars.schedule.last.get() == "21"
assert gate._cal_vars.schedule.count.get() == "3"
assert gate._cal_vars.schedule.times.get() == "07:00 14:00 21:00"
def test_a_refresh_does_not_clobber_an_open_edit(self, gate: MealGate) -> None:
"""Typing survives a calendar refresh landing mid-edit."""
gate._on_edit_or_save_schedule()
_type(gate, "6", "22", "6")
gate._refresh_calendar()
assert gate._cal_vars.schedule.first.get() == "6"
def test_a_refresh_shows_the_stored_schedule_when_idle(
self, gate: MealGate
) -> None:
"""Outside an edit the row tracks whatever is stored."""
record_schedule_change(MealSchedule(9, 19, 3))
gate._refresh_calendar()
assert gate._cal_vars.schedule.first.get() == "9"
assert gate._cal_vars.schedule.times.get() == "09:00 14:00 19:00"