From 6dd52a11ae5a552028162c5f26d49241851eb356 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 20:06:16 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn --- app/pubspec.yaml | 2 +- diet_guard/_gatelock_calendar_schedule.py | 29 ++++++++++++++----- diet_guard/_kuchnia_log.py | 26 +++++++---------- diet_guard/tests/_tk_fakes_containers.py | 7 +++++ .../tests/test_gatelock_calendar_schedule.py | 19 ++++++++++++ diet_guard/tests/test_kuchnia_import.py | 12 ++++---- diet_guard/tests/test_state_sync.py | 15 ++++++++++ docs/DOCS-meal-schedule.md | 8 ++--- 8 files changed, 83 insertions(+), 35 deletions(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8684a1d..d522a96 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/diet_guard/_gatelock_calendar_schedule.py b/diet_guard/_gatelock_calendar_schedule.py index dea4645..15cd112 100644 --- a/diet_guard/_gatelock_calendar_schedule.py +++ b/diet_guard/_gatelock_calendar_schedule.py @@ -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("", lambda _event: _step(1)) combo.bind("", lambda _event: _step(-1)) + combo.bind("", _press) def _time_combo(row: tk.Frame, variable: tk.StringVar) -> ttk.Combobox: diff --git a/diet_guard/_kuchnia_log.py b/diet_guard/_kuchnia_log.py index fd57d40..07de9b1 100644 --- a/diet_guard/_kuchnia_log.py +++ b/diet_guard/_kuchnia_log.py @@ -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 diff --git a/diet_guard/tests/_tk_fakes_containers.py b/diet_guard/tests/_tk_fakes_containers.py index 36c3083..b517ac4 100644 --- a/diet_guard/tests/_tk_fakes_containers.py +++ b/diet_guard/tests/_tk_fakes_containers.py @@ -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" diff --git a/diet_guard/tests/test_gatelock_calendar_schedule.py b/diet_guard/tests/test_gatelock_calendar_schedule.py index 02b72c1..0054308 100644 --- a/diet_guard/tests/test_gatelock_calendar_schedule.py +++ b/diet_guard/tests/test_gatelock_calendar_schedule.py @@ -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[""]) + 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.""" diff --git a/diet_guard/tests/test_kuchnia_import.py b/diet_guard/tests/test_kuchnia_import.py index d19c4ac..4612846 100644 --- a/diet_guard/tests/test_kuchnia_import.py +++ b/diet_guard/tests/test_kuchnia_import.py @@ -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([]) == [] diff --git a/diet_guard/tests/test_state_sync.py b/diet_guard/tests/test_state_sync.py index 4e2e023..f05642f 100644 --- a/diet_guard/tests/test_state_sync.py +++ b/diet_guard/tests/test_state_sync.py @@ -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) diff --git a/docs/DOCS-meal-schedule.md b/docs/DOCS-meal-schedule.md index f5dc10e..43eb41e 100644 --- a/docs/DOCS-meal-schedule.md +++ b/docs/DOCS-meal-schedule.md @@ -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