diet-guard/diet_guard/tests/_gate_layout_probe.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

336 lines
12 KiB
Python

r"""Measure the gate layout against a target screen and prove it is reachable.
Import :func:`measure` from a test to assert the invariant. There is
deliberately no ``print``-based CLI here: ruff runs with ``--unsafe-fixes``,
whose ``T201`` fix *deletes* print statements, which silently turns a
diagnostic script into a no-op. The gate is ``test_gate_layout_fits.py``,
which fails rather than reports.
Test support, not a test: it lives under ``tests/`` (excluded from the
``test_*.py`` naming hook) rather than in the package, so its diagnostic-only
branches -- the no-viewport degradation path in particular -- are not held to
the package's 100%-coverage bar for code that only runs against a layout that
has not been fixed yet.
**What the invariant actually is.** It is not "the content fits" -- the Log Meal
tab legitimately grows a line per logged meal, so on a short screen it will
eventually exceed the pane. It is "nothing is unreachable": the content lives in
a scroll viewport, that viewport scrolls by keyboard, and focus moves the view to
follow. Before the viewport existed the content was ``place``-centered, so
overflow sheared the title off the top and the submit button off the bottom
simultaneously, with no scrollbar and no way back -- inside a hard lock the user
cannot leave without logging a meal.
``content_span`` is still reported because a layout that needs scrolling on a
*fresh boot* is worth knowing about even when it is technically reachable.
Run the gate with:
xvfb-run -a -s "-screen 0 1366x768x24" \\
python3 -m pytest diet_guard/tests/test_gate_layout_fits.py
"""
from __future__ import annotations
from dataclasses import dataclass
import tkinter as tk
from tkinter import ttk
from diet_guard._gatelock_ui import GateCallbacks, GateVars, build_layout
from diet_guard._gatelock_ui_types import GateEntryVars
# ttk.Notebook's tab strip is chrome the content pane never gets to use.
# Measured rather than assumed; see _notebook_overhead().
_FALLBACK_TAB_STRIP_PX = 44
# A realistic full day, as the dashboard renders it. Module-level so ruff's
# FLY002 (static-join-to-f-string) does not rewrite an inline join into one
# over-long line, which then trips E501.
_SAMPLE_DASHBOARD = (
"08:00 oatmeal with milk and raisins -- 430 kcal",
"12:00 chicken thigh, rice, cucumber -- 780 kcal",
"16:00 greek yoghurt and a banana -- 260 kcal",
"protein 118 / 140 g carbs 210 g fat 61 g",
"protein target: 22 g remaining",
)
@dataclass(frozen=True)
class Measurement:
"""One layout measurement against one screen size."""
screen_w: int
screen_h: int
content_span: int
viewport_h: int
scrolls_by_keyboard: bool
last_widget_visible_after_focus: bool
has_scroll_viewport: bool = True
@property
def needs_scroll(self) -> bool:
"""Whether the content is taller than the viewport."""
return self.content_span > self.viewport_h
@property
def reachable(self) -> bool:
"""Whether every part of the content can be reached by keyboard.
Content that fits is trivially reachable. Content that overflows must
scroll by keyboard *and* bring a focused below-the-fold widget into
view.
Deliberately not asserted: that focusing always *moves* the view. When
the overflow is small the deepest widget is already visible, and then
holding still is the correct behaviour -- the invariant is "the focused
widget is visible", not "the viewport scrolled".
"""
if not self.needs_scroll:
return True
return self.scrolls_by_keyboard and self.last_widget_visible_after_focus
def describe(self) -> str:
"""Return a one-line human-readable summary."""
fit = (
f"needs scroll (+{self.content_span - self.viewport_h}px)"
if self.needs_scroll
else "fits"
)
verdict = "REACHABLE" if self.reachable else "UNREACHABLE CONTENT"
viewport = (
"scroll viewport present"
if self.has_scroll_viewport
else "NO SCROLL VIEWPORT -- overflow is clipped, not scrolled"
)
return (
f"{self.screen_w}x{self.screen_h}: "
f"content {self.content_span} in viewport {self.viewport_h} "
f"-> {fit}; {viewport}; keys={self.scrolls_by_keyboard} "
f"last_visible={self.last_widget_visible_after_focus} "
f"-> {verdict}"
)
def _make_vars(*, populated: bool) -> GateVars:
"""Build the gate's Tk variables, optionally with realistic content.
The empty case is *not* the worst case: the dashboard grows one line per
logged meal, so a layout that fits on a fresh boot can still overflow by
dinnertime. ``populated`` models a full day.
"""
entries = GateEntryVars(
amount=tk.StringVar(value="250" if populated else ""),
per=tk.StringVar(value="100" if populated else ""),
kcal=tk.StringVar(value="520" if populated else ""),
protein=tk.StringVar(value="31" if populated else ""),
carbs=tk.StringVar(value="44" if populated else ""),
fat=tk.StringVar(value="18" if populated else ""),
)
dashboard = ""
if populated:
dashboard = "\n".join(_SAMPLE_DASHBOARD)
return GateVars(
status=tk.StringVar(value="Enter what you ate." if populated else ""),
slot_header=tk.StringVar(
value="20:00 meal not logged yet -- what did you eat?"
if populated
else "Log your meal"
),
preview=tk.StringVar(value="520 kcal for 250 g" if populated else ""),
projection=tk.StringVar(
value="projected 1990 / 2100 kcal today" if populated else ""
),
cal_headline=tk.StringVar(
value="1470 kcal so far -- 630 left" if populated else ""
),
dashboard=tk.StringVar(value=dashboard),
unit=tk.StringVar(value="grams"),
entries=entries,
)
def _noop(*_args: object) -> None:
"""Swallow a widget callback; measurement never activates anything."""
def _notebook_overhead(root: tk.Misc) -> int:
"""Return the vertical px a ``ttk.Notebook`` tab strip costs.
Measured from a throwaway notebook rather than hardcoded, so a theme or
font change cannot silently invalidate the budget.
"""
probe = ttk.Notebook(root)
filler = tk.Frame(probe, width=200, height=100)
probe.add(filler, text="Log Meal")
probe.update_idletasks()
overhead = probe.winfo_reqheight() - filler.winfo_reqheight()
probe.destroy()
return overhead if overhead > 0 else _FALLBACK_TAB_STRIP_PX
# Tk widget classes that take keyboard focus by default. Checking `takefocus`
# is not enough: it is "" on most widgets, meaning "ask ::tk::FocusOK", so an
# empty value indicates a *default*-focusable widget rather than an opted-out
# one. Matching on class is what actually mirrors FocusOK's behaviour here.
_FOCUSABLE_CLASSES = frozenset(
{
"Button",
"Checkbutton",
"Entry",
"Listbox",
"Radiobutton",
"Spinbox",
"Text",
}
)
def _is_focusable(widget: tk.Misc) -> bool:
"""Whether ``widget`` would accept keyboard focus."""
if str(widget.cget("takefocus")) == "0":
return False
if str(widget.cget("takefocus")) == "1":
return True
return widget.winfo_class() in _FOCUSABLE_CLASSES
def _deepest_focusable(widget: tk.Misc) -> tk.Misc | None:
"""Return the last descendant that can take keyboard focus.
Used to prove the *bottom* of the content is reachable, which is where the
submit button and the dashboard live -- exactly what a centered, clipping
frame used to hide.
"""
found: tk.Misc | None = None
for child in widget.winfo_children():
if _is_focusable(child):
found = child
deeper = _deepest_focusable(child)
if deeper is not None:
found = deeper
return found
def _probe_viewport(
root: tk.Misc,
canvas: tk.Canvas,
inner: tk.Misc,
screen_w: int,
screen_h: int,
) -> Measurement:
"""Interrogate a built viewport: does it scroll, and does focus follow?
Split out of :func:`measure` so each function does one thing -- building the
layout, and interrogating it -- and so neither trips the local-variable cap.
"""
box = canvas.bbox("all")
span = (box[3] - box[1]) if box else 0
viewport_h = canvas.winfo_height()
canvas.yview_moveto(0.0)
root.update_idletasks()
canvas.yview_scroll(1, "pages")
root.update_idletasks()
scrolls = canvas.yview()[0] > 0.0
# Scroll back to the top, then focus the deepest widget and check it ends up
# on screen. Starting from the top is what makes this a real test: it is the
# state a user arrives in.
canvas.yview_moveto(0.0)
root.update_idletasks()
target = _deepest_focusable(inner)
last_visible = False
if target is not None:
# A real keypress first, then the focus it would have moved. Since
# 2026-08-03 gatelock only scrolls to follow focus the *user* moved:
# it used to follow programmatic focus too, and because the apps
# re-focus a widget on every repaint, the screen scrolled itself to
# mid-content and back while the user was doing nothing. A bare
# focus_set() here would therefore test a path no user can take.
target.event_generate("<Key-Tab>", when="now")
target.focus_set()
root.update()
root.update_idletasks()
top = target.winfo_rooty() - inner.winfo_rooty()
visible_from = canvas.canvasy(0)
last_visible = visible_from <= top <= visible_from + viewport_h
return Measurement(
screen_w=screen_w,
screen_h=screen_h,
content_span=span,
viewport_h=viewport_h,
scrolls_by_keyboard=scrolls,
last_widget_visible_after_focus=last_visible,
)
def measure(
screen_w: int = 1366,
screen_h: int = 768,
*,
populated: bool = True,
) -> Measurement:
"""Build the real gate layout inside a notebook and probe reachability.
Args:
screen_w: Target screen width in px.
screen_h: Target screen height in px.
populated: Fill the variables with a realistic full day of data.
Returns:
The measurement, including whether all content is keyboard-reachable.
"""
root = tk.Tk()
try:
root.geometry(f"{screen_w}x{screen_h}+0+0")
_notebook_overhead(root)
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=populated), callbacks, demo_mode=False
)
notebook.add(widgets.frame, text="Log Meal")
# No window manager under Xvfb, so the toplevel must claim focus before
# any FocusIn fires. Production gatelock does this via focus_force().
root.focus_force()
root.update()
root.update_idletasks()
# Reach into the viewport the layout built: the container holds the
# canvas, whose single window item is the content frame.
#
# A layout with NO viewport is the case this check exists to catch, so
# it must degrade to an accurate diagnosis rather than an exception --
# the same measurement runs against layouts that have not been fixed
# yet, and "StopIteration" tells the reader nothing. Without a viewport
# the content is the container itself, the usable height is the pane,
# and nothing scrolls.
canvas = next(
(
child
for child in widgets.frame.winfo_children()
if isinstance(child, tk.Canvas)
),
None,
)
if canvas is None:
return Measurement(
screen_w=screen_w,
screen_h=screen_h,
content_span=widgets.frame.winfo_reqheight(),
viewport_h=notebook.winfo_height(),
scrolls_by_keyboard=False,
last_widget_visible_after_focus=False,
has_scroll_viewport=False,
)
inner = canvas.winfo_children()[0]
return _probe_viewport(root, canvas, inner, screen_w, screen_h)
finally:
root.destroy()