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."
publish_to: 'none'
version: 1.0.108+1
version: 1.0.109+1
environment:
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`` with a fake -- keep binding it at module level.
**The dropdown is a popup over a lock that keeps itself on top.** Observed on
Xvfb (2026-10-10, demo gate): the ``ComboboxPopdown`` window maps but sits
*beneath* the lock surface, which gatelock re-raises; on the real lock its grab
watch also treats the popdown as "grab lost" and re-takes the grab. So the
list cannot be relied on: typing works, and Up/Down step the value by 15
minutes *without* posting the list (:func:`step_time`), which keeps the row
fully usable from the keyboard.
**The dropdown list is never posted.** Observed on Xvfb (2026-10-10, demo
gate): a posted ``ComboboxPopdown`` maps *beneath* the lock surface, which
gatelock keeps raised, yet still holds ttk's grab -- so the user sees nothing,
the next click anywhere is swallowed to unpost it, and keystrokes meant for
another field land in the combobox. On the real lock the grab watch also
fights the popdown for the grab. So both ways of posting are intercepted:
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
@ -45,6 +46,9 @@ __all__ = ["TIME_CHOICES", "build_schedule_row", "step_time"]
_ENTRY_WIDTH = 4
# "HH:MM" plus room for the caret, so a typed time is never clipped.
_TIME_WIDTH = 6
#: 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.
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:
"""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:
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.
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("<Down>", lambda _event: _step(-1))
combo.bind("<ButtonPress-1>", _press)
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._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_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_spread import SlottedDish
from diet_guard._meal_schedule import MealSchedule
#: Marks entries this importer created, so the provenance is visible in the log
#: and in the food bank's ``source`` column.
SOURCE = "kuchnia wikinga"
def _already_logged(
entries: Sequence[dict[str, object]],
name: str,
slot: int,
schedule: MealSchedule,
) -> bool:
"""Return True when today's log already holds this dish in this slot.
def _already_logged(entries: Sequence[dict[str, object]], name: str, slot: int) -> bool:
"""Return True when today's log already holds this dish at this slot minute.
"In this slot" means the entry *snaps* to ``slot`` (its nearest slot under
today's schedule), the same rule the gate uses to call a slot satisfied.
An exact-minute comparison would re-log a dish the phone wrote under a
schedule that has since moved by a few minutes -- a duplicate on every peer.
Compares the entry's *recorded* minute (:func:`entry_slot_minute`), not its
snapped slot -- the app's catering fill uses the same rule, which is the
point: both devices must agree on which dishes are duplicates, or one
re-logs what the other skipped. Snapping still guards the fill flow,
because :func:`fill_plan` skips any slot an entry snaps to (occupancy).
"""
wanted = name.strip().casefold()
return any(
str(entry.get("desc", "")).strip().casefold() == wanted
and slot in satisfied_by_entries((entry,), schedule)
and entry_slot_minute(entry) == slot
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.
"""
entries = today_entries()
schedule = current_schedule()
written: list[str] = []
for item in chosen:
dish = item.dish
if _already_logged(entries, dish.name, item.slot, schedule):
if _already_logged(entries, dish.name, item.slot):
continue
log_meal(dish.name, dish_nutrition(dish), item.slot)
# 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:
sequence, handler = args[0], args[1]
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 types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import patch
@ -74,6 +75,24 @@ class TestComboBindings:
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:
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."""

View File

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

View File

@ -185,6 +185,21 @@ class TestResignEntry:
with patch.object(_state_today, "_today", return_value="2026-06-22"):
assert today_entries() == [resigned]
def test_a_corrupt_peer_slot_is_relayed_not_raised_on(self) -> None:
"""Out-of-range peer values pass through re-signing and reading.
Only our own writers encode through ``slot_fields`` (which refuses an
out-of-range minute); a peer's ``slot: 24`` must ride through the
merge path unchanged -- raising here would stall every sync tick.
"""
peer = {"id": "p", "time": "2026-06-22T09:00:00+02:00", "kcal": 1.0}
resigned = resign_entry({**peer, "slot": 24, "slot_min": 5000})
assert (resigned["slot"], resigned["slot_min"]) == (24, 5000)
write_raw_log({"2026-06-22": [resigned]})
with patch.object(_state_today, "_today", return_value="2026-06-22"):
# 5000 snaps to the last slot rather than raising.
assert logged_slots_today() == {1200}
def test_no_op_signature_wise_when_no_key_available(self) -> None:
"""Without an HMAC key, resign_entry produces no hmac field."""
entry = log_meal("a", _nut(100), slot=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
(`_gatelock_scheduleedit.py`, widgets in `_gatelock_calendar_schedule.py`).
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
dropdown. Typing and Up/Down are the real paths: the popdown list maps
*beneath* the lock surface (gatelock keeps it raised), and on the real lock the
grab watch re-takes the grab from it.
and accepting any typed `HH:MM`; Up/Down step 15 minutes. The dropdown list is
**never posted** (arrow clicks and Down are intercepted): a posted popdown maps
*beneath* the lock surface, which gatelock keeps raised, while still holding
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
every grid slot exactly; an off-grid first/last meal is caught by the next