diet-guard/diet_guard/tests/test_gatelock_keyboard.py
Krzysztof Rudnicki 4a5e350baf Fit the meal gate on 1366x768 with a full day logged, and stop the auto-scroll
gatelock 0.4.0 (pinned here) stops the viewport following the app's own
focus_set(): on 2026-08-03 that moved the screen locker's lock screen to
mid-content and back with nobody touching the keyboard, and this gate uses
the same viewport. Scrolling now happens only on the user's own keypress,
wheel or click.

That makes fitting the screen the gate's own responsibility rather than
something a scrollbar covers for, and with a day of meals logged this one did
not: 770px of content in a 739px pane. Trimmed the vertical rhythm (the
title is one step down the scale, and the gaps around the description,
suggestions, macros and footer are one step tighter) for 718px, with room for
the dashboard to keep growing.

Its layout test now requires the gate to *fit* rather than merely be
reachable by scrolling, empty and populated. Dropped the 1024x600 tier: no
machine runs it, this gate cannot reach it without dropping content, and a
target nobody uses is a constraint that only ever costs.

The probe presses Tab before moving focus, because a bare focus_set() no
longer scrolls -- it would have been testing a path no user can take. The
Tk fakes learned pack_forget(), which the viewport calls to take the
scrollbar down whenever the content fits.

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

300 lines
11 KiB
Python

"""Tests for the gate's pointer-free operability.
Every assertion here stands in for something a keyboard-only user could not do
before. The gate is a hard lock -- ``overrideredirect`` + a global grab + VT
switching disabled -- so it cannot be left without submitting the form. A
control that needs a pointer is therefore not an inconvenience but a lockout,
which is why these are correctness tests rather than polish.
The reachability and height side of the same requirement is covered by
``measure_gate_layout.py``; the real focus-ring walk lives in
``probe_gate_keyboard.py``.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING, Any
import pytest
from diet_guard import _gatelock_buttons, _gatelock_typography, _gatelock_ui
from diet_guard._gatelock_ui import UNIT_GRAMS, UNIT_ITEMS
from diet_guard.tests import _gate_keyboard_probe
from diet_guard.tests.conftest import _FAKE_TK, FakeRadiobutton
if TYPE_CHECKING:
from diet_guard._gatelock import MealGate
class TestButtonReturnKey:
"""Enter must activate a button; Tk binds only Space on X11."""
def test_return_invokes_the_button(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The <Return> handler invokes the button and swallows the event."""
monkeypatch.setattr(_gatelock_buttons, "tk", _FAKE_TK)
fired: list[str] = []
bound: dict[str, Any] = {}
class _Button(FakeRadiobutton):
# Signature mirrors FakeWidget.bind so the override type-checks.
def bind(self, *args: object, **_kwargs: object) -> None:
bound[str(args[0])] = args[1]
def invoke(self) -> None:
fired.append("hit")
monkeypatch.setattr(_FAKE_TK, "Button", _Button)
_gatelock_buttons.make_button(
_FAKE_TK.Frame(),
text="Log & Continue",
variant="primary",
command=lambda: None,
)
assert bound["<Return>"](None) == "break"
assert fired == ["hit"]
def test_keypad_enter_is_bound_too(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The numeric keypad's Enter is a separate keysym from Return."""
monkeypatch.setattr(_gatelock_buttons, "tk", _FAKE_TK)
bound: dict[str, Any] = {}
class _Button(FakeRadiobutton):
# Signature mirrors FakeWidget.bind so the override type-checks.
def bind(self, *args: object, **_kwargs: object) -> None:
bound[str(args[0])] = args[1]
def invoke(self) -> None:
pass
monkeypatch.setattr(_FAKE_TK, "Button", _Button)
_gatelock_buttons.make_button(
_FAKE_TK.Frame(),
text="Close",
variant="danger",
command=lambda: None,
)
assert "<KP_Enter>" in bound
class TestUnitSelectorIsKeyboardReachable:
"""The grams/items selector must not be a ``tk.OptionMenu``.
``OptionMenu``'s ``Menubutton`` defaults to ``takefocus=0``, so it never
entered the tab ring -- and a posted Tk menu also steals the lock's grab,
which the recovery tick then kills. Radiobuttons avoid both.
"""
def test_no_option_menu_is_constructed(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Building the gate never constructs an OptionMenu.
Asserted behaviourally by making the constructor explode, rather than by
grepping the source -- a source scan matches this module's own
explanatory comments about the widget it replaced.
"""
def _explode(*_args: object, **_kwargs: object) -> None:
message = "OptionMenu is banned on a lock surface"
raise AssertionError(message)
monkeypatch.setattr(tk, "OptionMenu", _explode)
root = tk.Tk()
try:
selector_row = tk.Frame(root)
vars_ = _gatelock_ui.make_vars(root)
_gatelock_ui._build_unit_selector(selector_row, vars_, lambda _u: None)
classes = [child.winfo_class() for child in selector_row.winfo_children()]
assert classes == ["Radiobutton", "Radiobutton"]
assert "Menubutton" not in classes
finally:
root.destroy()
def test_radio_options_are_in_the_focus_ring(self) -> None:
"""Both units are tab stops -- the OptionMenu never was."""
root = tk.Tk()
try:
row = tk.Frame(root)
row.pack()
vars_ = _gatelock_ui.make_vars(root)
_gatelock_ui._build_unit_selector(row, vars_, lambda _u: None)
root.focus_force()
root.update()
root.update_idletasks()
for child in row.winfo_children():
assert str(child.cget("takefocus")) != "0"
finally:
root.destroy()
def test_selecting_a_radio_fires_the_unit_callback(self) -> None:
"""Activating an option updates the var and notifies the controller."""
root = tk.Tk()
try:
row = tk.Frame(root)
vars_ = _gatelock_ui.make_vars(root)
seen: list[str] = []
_gatelock_ui._build_unit_selector(row, vars_, seen.append)
items = [
child
for child in row.winfo_children()
if str(child.cget("value")) == UNIT_ITEMS
]
items[0].invoke()
assert vars_.unit.get() == UNIT_ITEMS
assert seen == [UNIT_ITEMS]
finally:
root.destroy()
def test_both_units_are_offered(self, gate: MealGate) -> None:
"""Selecting each radio option drives the unit variable."""
assert gate._vars.unit.get() == UNIT_GRAMS
gate._vars.unit.set(UNIT_ITEMS)
assert gate._vars.unit.get() == UNIT_ITEMS
class TestNotebookTraversal:
"""Ctrl+Tab / Ctrl+PageDown tab switching must be enabled explicitly."""
def test_traversal_is_enabled(self, gate: MealGate) -> None:
"""``enable_traversal()`` is called, or the tab keys are dead.
ttk installs those toplevel bindings only on request, so omitting the
call leaves Ctrl+Tab, Ctrl+PageUp/Down and Alt+mnemonic doing nothing
while looking perfectly normal in the code.
"""
assert gate._notebook.traversal_enabled is True
class TestFocusRingIsVisible:
"""The focus ring must be the accent, not Tk's black default."""
def test_focus_kwargs_sets_the_focused_ring(self) -> None:
"""``highlightcolor`` (focused) is the accent and thickness is non-zero.
Setting only ``highlightbackground`` -- the *unfocused* ring -- is the
bug this replaced: the widget outlined while unfocused and went black
against ``bg`` the moment it took focus.
"""
kwargs = _gatelock_ui._COLORS.focus_kwargs()
assert kwargs["highlightcolor"] == _gatelock_ui._COLORS.accent
assert kwargs["highlightcolor"] != "#000000"
assert int(kwargs["highlightthickness"]) > 0
def test_prose_wrap_respects_the_line_length_cap(self) -> None:
"""Wrap width is the 640px cap, not the old 900px literal."""
assert _gatelock_ui._WRAP_PX == 640
class TestTypeScaleIsPixels:
"""Tk font sizes must be negative, i.e. pixels rather than points."""
@pytest.mark.parametrize(
"size",
[
_gatelock_typography.DISPLAY,
_gatelock_typography.SUBTITLE,
_gatelock_typography.BODY,
_gatelock_typography.LABEL,
_gatelock_typography.CAPTION,
],
)
def test_sizes_are_negative(self, size: int) -> None:
"""A positive size means points and renders ~30% oversized.
Measured: body at 16 points has a 26px linespace where 16 pixels gives
20px. That inflation alone pushed this layout off a 768px screen.
"""
assert size < 0
def test_scale_matches_the_design_system_magnitudes(self) -> None:
"""The magnitudes are still tokens.md's 32/24/20/16/14/12 scale."""
assert abs(_gatelock_typography.DISPLAY) == 32
assert abs(_gatelock_typography.TITLE) == 24
assert abs(_gatelock_typography.SUBTITLE) == 20
assert abs(_gatelock_typography.BODY) == 16
assert abs(_gatelock_typography.LABEL) == 14
assert abs(_gatelock_typography.CAPTION) == 12
class TestRealFocusRing:
"""Walk the ring Tab actually follows, against a real Tk.
Reading the code cannot answer this: Tk decides via ``::tk::FocusOK``, which
also rejects any widget whose *class* has no key bindings. That is why a
Canvas viewport was pointer-only and a Menubutton was skipped entirely --
neither looks unusual in source.
"""
def test_unit_selector_is_in_the_ring(self) -> None:
"""Both grams/items radio options are tab stops.
The ``OptionMenu`` they replaced was absent from the ring entirely, so
the unit -- which rescales the whole entry -- could only be changed with
a pointer, inside a lock requiring a logged meal to exit.
"""
report = _gate_keyboard_probe.probe()
assert report.classes.count("Radiobutton") == 2, report.describe()
def test_every_focusable_class_has_a_visible_ring(self) -> None:
"""No widget in the ring keeps Tk's black default highlight.
Tk ships ``highlightcolor="#000000"`` on a ``#211D1B`` background, so
the default is a ring that cannot be seen.
"""
report = _gate_keyboard_probe.probe()
invisible = {
name: color
for name, color in report.focus_rings_visible.items()
if color.lower() in {"", "#000000", "black"}
}
assert not invisible, f"invisible focus rings: {invisible}"
def test_scroll_viewport_is_a_focus_stop(self) -> None:
"""The Canvas viewport is reachable, so it can be scrolled by keyboard.
A bare ``tk.Canvas`` has no class key bindings and is therefore rejected
by ``FocusOK``; it only appears here because it opts in explicitly.
"""
report = _gate_keyboard_probe.probe()
assert report.has("Canvas"), report.describe()
def test_text_escapes_and_buttons_take_return(self) -> None:
"""The two Tk defaults that make a form unusable are both overridden."""
report = _gate_keyboard_probe.probe()
assert report.text_escapes_tab, report.describe()
assert report.buttons_take_return, report.describe()
class TestRealTkKeyboardBehaviour:
"""End-to-end checks against a real Tk, not the fakes.
The fakes cannot prove anything about Tk's own class bindings, and those
bindings are the whole problem: Tab-traps and missing Return bindings are
Tk defaults, not application code.
"""
def test_text_tab_escape_against_real_tk(self) -> None:
"""A real Text widget releases focus on Tab."""
root = tk.Tk()
try:
first = tk.Text(root)
first.pack()
second = tk.Entry(root)
second.pack()
_gatelock_ui._escape_text_tab_trap(first)
root.focus_force()
root.update()
first.focus_set()
root.update()
first.event_generate("<Tab>")
root.update()
root.update_idletasks()
assert root.focus_get() is not first
# And no literal tab was inserted.
assert "\t" not in first.get("1.0", "end")
finally:
root.destroy()