mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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:
parent
4e5c6b453e
commit
6dd52a11ae
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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([]) == []
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user