mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
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
195 lines
8.0 KiB
Python
195 lines
8.0 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,
|
|
)
|
|
|
|
|
|
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,
|
|
)
|
|
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()
|