diet-guard/diet_guard/tests/_gate_layout_probe.py
Krzysztof kuhy Rudnicki a7e3685963
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
gate + CLI: fill today's empty slots from catering in one confirmed step
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 13:12:15 +02:00

199 lines
8.2 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
import tkinter as tk
from tkinter import ttk
from diet_guard._gatelock_layout import build_layout
from diet_guard._gatelock_ui import GateCallbacks, GateVars
from diet_guard._gatelock_ui_types import GateEntryVars
from diet_guard.tests._gate_focus_probe import _probe_viewport
from diet_guard.tests._gate_measurement import Measurement
# 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",
)
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,
# The wider of the button's two labels, so the row is measured at worst.
fill_label=tk.StringVar(value="✓ Confirm (5)" if populated else "🍱 Fill all"),
)
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
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:
# Bypass the window manager. This measures whether the layout fits a
# given panel, so the window must actually BE that size -- but a
# tiling WM (i3 on the dev machine) retiles a managed toplevel to the
# workspace instead, and `winfo_height()` then reports 0/1px. Every
# measurement taken from that is noise, and the assertions fail on it
# rather than on any real layout regression. `overrideredirect` takes
# the window out of the WM's control, so the requested geometry is
# honoured exactly -- verified 1366x768 under i3, and unchanged under
# a bare Xvfb, which has no WM to bypass in the first place.
root.overrideredirect(boolean=True)
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,
on_load_delivery=_noop,
on_fill_all=_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()