mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Pulls the day's delivered dishes and their real per-portion macros out of
panel.kuchniavikinga.pl, so a catering meal is one keypress instead of five
retyped numbers.
A delivered meal is not an eaten meal, so nothing is ever logged unattended:
dishes land in the *curated* bank, and entries are written only after an
explicit confirmation. Auto-logging would let the gate satisfy its own
checkpoint from a delivery note, and would record what the courier dropped off
rather than what was eaten.
Three surfaces:
- `diet-guard kuchnia [--log] [--yes]`
- the lock screen's "Today's delivery" button, which prefills the meal form
dish by dish (the user still clicks Log & Continue)
- a warm-up after each logged meal, on the existing background thread
All event-driven; no new systemd timer. The two automatic paths go through
`refresh_delivery_once`, guarded to one fetch per day.
The panel is an undocumented white-labelled Dietly API; the endpoint map was
recovered from its JS bundles and confirmed against live responses. Notes,
including the traps, are in docs/kuchnia-wikinga.md.
Load-bearing details:
* Macros are per portion and `weight` is that portion in grams -- proven, not
assumed: 4P+4C+9F reproduces the stated calories within ~1% on every meal of
the captured day. `_energy_is_consistent` re-runs that check per dish and
drops any that fails; a per-100g mix-up is invisible in a payload and would
silently skew every logged meal by a factor of several.
* `bank_dishes` compares nutritional fields before calling `add_manual_entry`,
which restamps `t` unconditionally. Without the compare, every refresh
republishes the whole curated bank to every peer. Tests assert on call count.
* Duplicate log suppression reads today's synced log, not a local marker:
`log_meal` mints a fresh uuid, so a re-import after marker loss would merge
as duplicates on every peer.
* The gate hook is in `on_focus_ready`, not `_cli_gate._should_lock`:
`gate_is_due()` never reads the food bank, so a refresh there could not
change the lock decision while adding a third-party round trip to the ~105ms
`gate --check` fast path.
* `requests` is lazy-imported so the gate's not-due tick does not pay for it.
* Credentials and the cached session cookie live in ~/.config/diet_guard at
mode 600, outside the synced tree. The credentials file is written by hand,
never by the package -- the same contract as sync_token.
`_GateDashboard` is merged into `_GateMealFlow`, and both "pull it in from
elsewhere" flows share one `_PullFlows` mixin: pylint's max-parents counts
every MRO entry, so the gate's controller chain had no room for a new class.
Also adds a side-by-side row for the two pull buttons -- stacking them cost
42px and pushed the tab past the viewport at 1366x768.
989 tests, 100% branch coverage, pre-commit clean.
141 lines
5.2 KiB
Python
141 lines
5.2 KiB
Python
"""Walk the gate's real focus ring and report keyboard reachability.
|
|
|
|
Reading the code is not enough to know what is reachable: Tk decides via
|
|
``::tk::FocusOK``, which consults ``takefocus``, mapped-ness, *and* whether the
|
|
widget's class has any key bindings at all. A ``tk.Canvas`` fails that last test
|
|
even though it looks like a normal widget, and a ``Menubutton`` fails the first.
|
|
So this walks ``tk_focusNext`` the way the Tab key does.
|
|
|
|
There is deliberately no ``print``-based CLI: ruff runs with
|
|
``--unsafe-fixes``, whose ``T201`` fix *deletes* print statements, silently
|
|
turning a diagnostic script into a no-op. :func:`probe` is consumed by
|
|
``tests/test_gatelock_keyboard.py``, which fails rather than reports.
|
|
|
|
Test support, not a test: it lives under ``tests/`` and is excluded from the
|
|
``test_*.py`` naming hook.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
from diet_guard._gatelock_layout import build_layout
|
|
from diet_guard._gatelock_ui import GateCallbacks
|
|
from diet_guard.tests._gate_layout_probe import _make_vars, _noop
|
|
|
|
# How far to walk before declaring the ring non-terminating.
|
|
_MAX_RING_STEPS = 60
|
|
|
|
|
|
@dataclass
|
|
class RingReport:
|
|
"""What a focus-ring walk found."""
|
|
|
|
classes: list[str] = field(default_factory=list)
|
|
focus_rings_visible: dict[str, str] = field(default_factory=dict)
|
|
text_escapes_tab: bool = False
|
|
buttons_take_return: bool = False
|
|
|
|
def has(self, widget_class: str) -> bool:
|
|
"""Whether the ring includes at least one widget of this class."""
|
|
return widget_class in self.classes
|
|
|
|
def describe(self) -> str:
|
|
"""Return a multi-line human-readable summary."""
|
|
lines = [f"focus ring ({len(self.classes)} stops):"]
|
|
lines.append(" " + " -> ".join(self.classes) + " -> (wrap)")
|
|
lines.append("focus-ring colors by class:")
|
|
for name, color in sorted(self.focus_rings_visible.items()):
|
|
verdict = (
|
|
"visible"
|
|
if color.lower() not in {"", "#000000", "black"}
|
|
else "INVISIBLE"
|
|
)
|
|
lines.append(f" {name:<12} highlightcolor={color or '<unset>'} {verdict}")
|
|
lines.append(f"Text escapes Tab : {self.text_escapes_tab}")
|
|
lines.append(f"Buttons accept Return : {self.buttons_take_return}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _walk_ring(start: tk.Misc, limit: int = _MAX_RING_STEPS) -> list[tk.Misc]:
|
|
"""Return the widgets Tab visits, starting from ``start``, until it wraps."""
|
|
seen: list[tk.Misc] = []
|
|
current = start
|
|
for _ in range(limit):
|
|
if current in seen:
|
|
break
|
|
seen.append(current)
|
|
current = current.tk_focusNext()
|
|
if current is None:
|
|
break
|
|
return seen
|
|
|
|
|
|
def probe(screen_w: int = 1366, screen_h: int = 768) -> RingReport:
|
|
"""Build the gate and probe its keyboard behaviour."""
|
|
root = tk.Tk()
|
|
try:
|
|
# Bypass the window manager -- see the same call in
|
|
# `_gate_layout_probe.measure`. A tiling WM retiles this toplevel
|
|
# instead of honouring the geometry, the widgets never realize at a
|
|
# usable size, and the focus ring then walks a single stop, failing
|
|
# every traversal assertion for reasons that have nothing to do with
|
|
# the focus order under test.
|
|
root.overrideredirect(boolean=True)
|
|
root.geometry(f"{screen_w}x{screen_h}+0+0")
|
|
notebook = ttk.Notebook(root)
|
|
notebook.place(relx=0, rely=0, relwidth=1, relheight=1)
|
|
callbacks = GateCallbacks(
|
|
on_unit_change=_noop,
|
|
on_submit=_noop,
|
|
on_close=_noop,
|
|
on_fetch_sync=_noop,
|
|
on_load_delivery=_noop,
|
|
)
|
|
widgets = build_layout(
|
|
notebook, _make_vars(populated=True), callbacks, demo_mode=False
|
|
)
|
|
notebook.add(widgets.frame, text="Log Meal")
|
|
notebook.enable_traversal()
|
|
root.focus_force()
|
|
root.update()
|
|
root.update_idletasks()
|
|
|
|
report = RingReport()
|
|
ring = _walk_ring(widgets.desc_text)
|
|
for widget in ring:
|
|
report.classes.append(widget.winfo_class())
|
|
try:
|
|
color = str(widget.cget("highlightcolor"))
|
|
except tk.TclError:
|
|
continue
|
|
report.focus_rings_visible.setdefault(widget.winfo_class(), color)
|
|
|
|
# Tab out of the description box: it must not stay focused.
|
|
widgets.desc_text.focus_set()
|
|
root.update()
|
|
widgets.desc_text.event_generate("<Tab>")
|
|
root.update()
|
|
root.update_idletasks()
|
|
report.text_escapes_tab = root.focus_get() is not widgets.desc_text
|
|
|
|
# Return must invoke a button (Tk binds only <space> on X11).
|
|
fired: list[str] = []
|
|
probe_button = next(
|
|
(w for w in ring if isinstance(w, tk.Button)),
|
|
None,
|
|
)
|
|
if probe_button is not None:
|
|
probe_button.configure(command=lambda: fired.append("hit"))
|
|
probe_button.focus_set()
|
|
root.update()
|
|
probe_button.event_generate("<Return>")
|
|
root.update()
|
|
root.update_idletasks()
|
|
report.buttons_take_return = bool(fired)
|
|
return report
|
|
finally:
|
|
root.destroy()
|