diet-guard/diet_guard/tests/_gate_keyboard_probe.py
Krzysztof kuhy Rudnicki e8f0ecaae1 Split the six Python gate files; guard the fake-tk patch set
Sixth batch of the 250-line sweep. All six gate sources named for this batch
are now under cap, plus _gate_layout_probe.py, which this batch's import
rewiring pulled into the changeset (over-cap count 38 -> 31).

  _gatelock_ui.py       477 -> 174  (palette/leaf-helper hub)
  _gate_layout_probe.py 346 -> 196
  _gatelock_groups.py   308 -> 137
  _gatelock_calendar_ui 295 ->  77
  _gatelock_calendar.py 286 -> 210
  _gatelock.py          263 -> 238
  _gatelock_mealflow.py 259 -> 207

New siblings: _gatelock_fields (input widgets), _gatelock_layout (assembly),
_gatelock_widgetgroups (per-widget mirroring), _gatelock_calendar_types
(bundles/palette), _gatelock_calendar_widgets (History builders),
_gatelock_dashboard and _gatelock_budgetedit (mixins), _gatelock_lockfile
(the stdlib-only flock), _gate_focus_probe and _gate_measurement.

The load-bearing part is the test seam, not the line counts.
`_gate_fixtures._GATE_TK_MODULES` patches `tk` on a hand-listed tuple, and
that list fails OPEN: a widget builder split into a new sibling that nobody
adds to it builds *real* Tk widgets against fake parents. Neither existing
detector sees that -- check_patch_targets.py only validates paths that still
resolve, and the ~8s runtime only catches network calls. It bit twice during
this split (real StringVars from make_calendar_vars returned MagicMocks
instead of the asserted text), so test_gate_tk_modules.py now asserts the
invariant directly: every module binding `tk` is in the patch set or named in
_NO_WIDGETS with a reason. It caught a pre-existing gap too --
_gatelock_support was never in the set.

Same fail-open shape drove two other choices: `escape_tab_trap` is a call-time
indirection so the keyboard test's patch of `_escape_text_tab_trap` still
bites, and the flock pair moved with its conftest patch target updated in the
same edit (that one fails closed). build_layout is imported from
_gatelock_layout directly rather than re-exported lazily, which avoids both a
cycle and an import-outside-toplevel suppression.

Verified: 703 Python tests / 100% branch coverage, the 5 layout-fits tests
under xvfb, 555 Flutter tests (unchanged), flutter analyze clean,
check_patch_targets 0, `sync` 0 (403 entries), pre-commit clean, and
`gate --demo` renders both tabs with live dashboard data.
~/.local/share/diet_guard byte-identical across the batch (gate timer paused,
restored after).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 20:08:03 +02:00

140 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,
)
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()