slots: never post the schedule combobox list; kuchnia dedup on raw minute

- The popdown maps beneath the gatelock surface yet holds ttk's grab
  (verified on Xvfb: invisible, swallows the next click, steals keys), so
  arrow clicks are intercepted as Down already was. Typing and Up/Down stay.
- Catering dedup compares the entry's recorded minute (parity with the
  app); occupancy (fill_plan) still snaps.
- Test: a corrupt peer slot (slot 24, slot_min 5000) re-signs and reads
  without raising.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 20:06:16 +02:00
parent 4e5c6b453e
commit 6dd52a11ae
8 changed files with 83 additions and 35 deletions

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go." description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none' publish_to: 'none'
version: 1.0.108+1 version: 1.0.109+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -10,13 +10,14 @@ offers every quarter hour, and the user may also type any ``HH:MM``. The
``ttk`` import is load-bearing for the tests, which patch this module's ``ttk`` import is load-bearing for the tests, which patch this module's
``ttk`` with a fake -- keep binding it at module level. ``ttk`` with a fake -- keep binding it at module level.
**The dropdown is a popup over a lock that keeps itself on top.** Observed on **The dropdown list is never posted.** Observed on Xvfb (2026-10-10, demo
Xvfb (2026-10-10, demo gate): the ``ComboboxPopdown`` window maps but sits gate): a posted ``ComboboxPopdown`` maps *beneath* the lock surface, which
*beneath* the lock surface, which gatelock re-raises; on the real lock its grab gatelock keeps raised, yet still holds ttk's grab -- so the user sees nothing,
watch also treats the popdown as "grab lost" and re-takes the grab. So the the next click anywhere is swallowed to unpost it, and keystrokes meant for
list cannot be relied on: typing works, and Up/Down step the value by 15 another field land in the combobox. On the real lock the grab watch also
minutes *without* posting the list (:func:`step_time`), which keeps the row fights the popdown for the grab. So both ways of posting are intercepted:
fully usable from the keyboard. a click on the arrow element (:data:`_ARROW_ELEMENT`) and Down. Typing any
``HH:MM`` works, and Up/Down step by 15 minutes (:func:`step_time`).
""" """
from __future__ import annotations from __future__ import annotations
@ -45,6 +46,9 @@ __all__ = ["TIME_CHOICES", "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. # "HH:MM" plus room for the caret, so a typed time is never clipped.
_TIME_WIDTH = 6 _TIME_WIDTH = 6
#: What ``Combobox.identify`` names the arrow under the gate's ``clam`` theme
#: (verified on Xvfb: ``Combobox.field`` | ``textarea`` | ``downarrow``).
_ARROW_ELEMENT = "downarrow"
#: Every quarter hour of the day, the grid interior slots are rounded onto. #: Every quarter hour of the day, the grid interior slots are rounded onto.
TIME_CHOICES = tuple( TIME_CHOICES = tuple(
@ -80,7 +84,7 @@ def step_time(text: str, direction: int) -> str:
def _bind_steps(combo: ttk.Combobox, variable: tk.StringVar) -> None: def _bind_steps(combo: ttk.Combobox, variable: tk.StringVar) -> None:
"""Make Up/Down step the time instead of posting the dropdown list.""" """Make Up/Down step the time, and keep the dropdown list from posting."""
def _step(direction: int) -> str: def _step(direction: int) -> str:
if str(combo.cget("state")) == "normal": if str(combo.cget("state")) == "normal":
@ -88,8 +92,17 @@ def _bind_steps(combo: ttk.Combobox, variable: tk.StringVar) -> None:
# "break" stops ttk's own binding, which would post the popdown. # "break" stops ttk's own binding, which would post the popdown.
return "break" return "break"
def _press(event: tk.Event[ttk.Combobox]) -> str | None:
# A click on the arrow would post the popdown -- see the module
# docstring for why that is unsafe here. Clicks on the text still
# fall through to ttk, so the caret lands where the user clicked.
if _ARROW_ELEMENT in str(combo.identify(event.x, event.y)):
return "break"
return None
combo.bind("<Up>", lambda _event: _step(1)) combo.bind("<Up>", lambda _event: _step(1))
combo.bind("<Down>", lambda _event: _step(-1)) combo.bind("<Down>", lambda _event: _step(-1))
combo.bind("<ButtonPress-1>", _press)
def _time_combo(row: tk.Frame, variable: tk.StringVar) -> ttk.Combobox: def _time_combo(row: tk.Frame, variable: tk.StringVar) -> ttk.Combobox:

View File

@ -19,7 +19,7 @@ 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._meal_schedule_store import current_schedule
from diet_guard._slot_wire import satisfied_by_entries, slot_fields 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
@ -28,30 +28,25 @@ if TYPE_CHECKING: # pragma: no cover - typing only
from diet_guard._kuchnia_parse import Dish from diet_guard._kuchnia_parse import Dish
from diet_guard._kuchnia_spread import SlottedDish from diet_guard._kuchnia_spread import SlottedDish
from diet_guard._meal_schedule import MealSchedule
#: Marks entries this importer created, so the provenance is visible in the log #: Marks entries this importer created, so the provenance is visible in the log
#: and in the food bank's ``source`` column. #: and in the food bank's ``source`` column.
SOURCE = "kuchnia wikinga" SOURCE = "kuchnia wikinga"
def _already_logged( def _already_logged(entries: Sequence[dict[str, object]], name: str, slot: int) -> bool:
entries: Sequence[dict[str, object]], """Return True when today's log already holds this dish at this slot minute.
name: str,
slot: int,
schedule: MealSchedule,
) -> bool:
"""Return True when today's log already holds this dish in this slot.
"In this slot" means the entry *snaps* to ``slot`` (its nearest slot under Compares the entry's *recorded* minute (:func:`entry_slot_minute`), not its
today's schedule), the same rule the gate uses to call a slot satisfied. snapped slot -- the app's catering fill uses the same rule, which is the
An exact-minute comparison would re-log a dish the phone wrote under a point: both devices must agree on which dishes are duplicates, or one
schedule that has since moved by a few minutes -- a duplicate on every peer. 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 slot in satisfied_by_entries((entry,), schedule) and entry_slot_minute(entry) == slot
for entry in entries for entry in entries
) )
@ -101,11 +96,10 @@ def log_dishes(chosen: Sequence[SlottedDish]) -> list[str]:
The descriptions actually written, in the order they were logged. The descriptions actually written, in the order they were logged.
""" """
entries = today_entries() entries = today_entries()
schedule = current_schedule()
written: list[str] = [] written: list[str] = []
for item in chosen: for item in chosen:
dish = item.dish dish = item.dish
if _already_logged(entries, dish.name, item.slot, schedule): if _already_logged(entries, dish.name, item.slot):
continue continue
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

View File

@ -206,3 +206,10 @@ class FakeCombobox(FakeWidget):
def bind(self, *args: object, **kwargs: object) -> None: def bind(self, *args: object, **kwargs: object) -> None:
sequence, handler = args[0], args[1] sequence, handler = args[0], args[1]
self.bindings[str(sequence)] = handler self.bindings[str(sequence)] = handler
#: Where the fake's arrow element starts; left of it is the text area.
ARROW_X = 50
def identify(self, x: int, y: int) -> str:
"""Name the element at ``x``, as clam does: text, then the arrow."""
return "downarrow" if x >= self.ARROW_X else "textarea"

View File

@ -7,6 +7,7 @@ per-widget state toggle in :mod:`._gatelock_scheduleedit`.
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from unittest.mock import patch from unittest.mock import patch
@ -74,6 +75,24 @@ class TestComboBindings:
assert gate._cal_vars.schedule.first.get() == "07:45" assert gate._cal_vars.schedule.first.get() == "07:45"
class TestArrowNeverPosts:
"""The popdown would map beneath the lock yet hold the grab."""
def _press(self, gate: MealGate, x: int) -> object:
combo = cast("FakeCombobox", gate._cal_widgets.schedule_last_entry)
handler = cast("Callable[[object], object]", combo.bindings["<ButtonPress-1>"])
return handler(SimpleNamespace(x=x, y=5))
def test_a_click_on_the_arrow_is_swallowed(self, gate: MealGate) -> None:
gate._on_edit_or_save_schedule()
assert self._press(gate, 60) == "break"
def test_a_click_on_the_text_reaches_ttk(self, gate: MealGate) -> None:
"""So the caret still lands where the user clicked."""
gate._on_edit_or_save_schedule()
assert self._press(gate, 10) is None
class TestStateToggle: class TestStateToggle:
def test_one_dead_widget_does_not_skip_the_rest(self, gate: MealGate) -> None: def test_one_dead_widget_does_not_skip_the_rest(self, gate: MealGate) -> None:
"""A vanished monitor's combobox must not leave the others locked.""" """A vanished monitor's combobox must not leave the others locked."""

View File

@ -231,13 +231,13 @@ class TestLogDishes:
log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=480)]) log_dishes([SlottedDish(dish=_dish("Kaszotto"), slot=480)])
assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=480)]) == [] assert log_dishes([SlottedDish(dish=_dish(" kaszotto "), slot=480)]) == []
def test_a_dish_logged_near_the_slot_is_not_relogged(self) -> None: def test_dedup_compares_the_recorded_minute_not_the_snapped_slot(self) -> None:
# The phone logged it at 08:15 under an older schedule: it snaps to # Parity with the app: dedup is (name, recorded minute). An 08:15 copy
# today's 08:00, so re-importing must not write a second 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)]) log_dishes([SlottedDish(dish=_dish("A"), slot=495)])
assert log_dishes([SlottedDish(dish=_dish("A"), slot=480)]) == [] assert log_dishes([SlottedDish(dish=_dish("A"), slot=495)]) == []
(entry,) = today_entries() assert log_dishes([SlottedDish(dish=_dish("A"), slot=480)]) == ["A"]
assert entry["slot_min"] == 495
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([]) == []

View File

@ -185,6 +185,21 @@ 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_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=480) entry = log_meal("a", _nut(100), slot=480)

View File

@ -85,10 +85,10 @@ 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`, widgets in `_gatelock_calendar_schedule.py`). (`_gatelock_scheduleedit.py`, widgets in `_gatelock_calendar_schedule.py`).
The gate's first/last fields are `ttk.Combobox`es offering every quarter hour The gate's first/last fields are `ttk.Combobox`es offering every quarter hour
and accepting any typed `HH:MM`; Up/Down step 15 minutes without posting the and accepting any typed `HH:MM`; Up/Down step 15 minutes. The dropdown list is
dropdown. Typing and Up/Down are the real paths: the popdown list maps **never posted** (arrow clicks and Down are intercepted): a posted popdown maps
*beneath* the lock surface (gatelock keeps it raised), and on the real lock the *beneath* the lock surface, which gatelock keeps raised, while still holding
grab watch re-takes the grab from it. ttk's grab — invisible, it swallows the next click and steals keystrokes.
`diet-guard-gate.timer` fires every 15 minutes (`*:00/15:00`), so it lands on `diet-guard-gate.timer` fires every 15 minutes (`*:00/15:00`), so it lands on
every grid slot exactly; an off-grid first/last meal is caught by the next every grid slot exactly; an off-grid first/last meal is caught by the next