screen-locker/scripts/verify_screen_fits.py
Krzysztof kuhy Rudnicki 76a4c6a0a7 Finish the Python side: scripts, conftest, and the last test modules
verify_lock_popup_safety.py (318) -> _xvfb_reexec.py      shared re-exec
                                       _popup_form_check.py HostileLock, ticks,
                                                            the real-form check
  verify_screen_fits.py (266)       -> uses _xvfb_reexec too
  conftest.py (329)                 -> _tk_mocks.py         the tk mock + the
                                                            module patch list
                                       _opt_in_fixtures.py  requestable fixtures
  three test modules                -> _part4/_part5/_part15 siblings

conftest keeps every autouse fixture. pytest applies autouse fixtures from
conftest only, so one moved to a sibling would silently stop running and take
its safety guard with it -- verified by asserting the fixtures are still
listed, not just that the suite is green. The five near-identical _isolate_*
fixtures collapse into one table-driven _isolate_state_files, which also picks
up the _shutdown_sick_state binding an earlier split created.

scripts/ becomes a real package so the shared helpers are importable. Both
gates now run as `python3 -m scripts.<name>`, including CI -- the path form put
scripts/ on sys.path instead of the repo root, and the alternative was a
try/except ImportError that this repo's no-silent-failures hook rightly
rejects. Each gate was run in its exact invocation form.

Note: verify_lock_popup_safety still fails on
`RecoveryLoop.__init__() takes 3 positional arguments but 6 were given`. That
is pre-existing -- confirmed by running it at HEAD in a clean worktree before
touching it -- a gatelock API drift, not this refactor. Left as found.

978 tests at 100% branch coverage. Gate: 41 -> 22 files, all remaining ones
Dart plus DESIGN_AUDIT_TODO.md.

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

240 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Assert every lock screen fits a 1366x768 display.
A lock surface is fullscreen, grabbed and (in production) VT-locked. Content
that does not fit is only reachable by scrolling a screen that should never
have needed a scrollbar -- and the scroll that revealed it used to happen on
its own, moving the screen under a user who had touched nothing (2026-08-03).
The runtime half of this guarantee is the ``warning``
``ScrollableSurface.finalize`` logs; this script is the half that runs before
the code ships, so a layout change cannot quietly reintroduce overflow.
``StatusWindow`` is deliberately absent: it is a normal, resizable,
dismissible window rather than a lock surface, so a user who needs more of it
can resize it. Every screen that grabs the display is here.
Not a pytest test because ``screen_locker/tests/conftest.py`` replaces the
whole ``tk`` module for every test so the suite can never reach a display, and
widget heights can only be measured by really rendering them. Like
``verify_lock_popup_safety.py``, this re-executes itself under Xvfb:
cd ~/screen-locker && python3 -m scripts.verify_screen_fits
Run as a *module*, not a path: ``-m`` puts the repo root on ``sys.path`` so
``screen_locker`` resolves from the checkout. Running it as a path only works
where the package happens to be pip-installed, which is why it passed here and
died in CI.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import os
from pathlib import Path
import sys
from typing import TYPE_CHECKING
from gatelock import LockConfig, measure_fit, report_fit
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
from screen_locker._heat_skip import build_heat_skip_content
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
from screen_locker._sick_dialog import SickDialogMixin
from screen_locker._surface_group import FrameGroup
from screen_locker._ui_flows import UIFlowsMixin
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
from screen_locker._ui_widgets import UIWidgetsMixin
from scripts._xvfb_reexec import reexec_under_xvfb
if TYPE_CHECKING:
from collections.abc import Callable
import tkinter as tk
from gatelock import FitResult, ScrollableSurface
_logger = logging.getLogger(__name__)
INNER_ENV = "_SCREEN_FITS_CHECK_INNER"
# The panel this app has to support: the laptop it runs on. Shorter panels
# get gatelock's best-effort compaction (``gatelock._density``) but are not
# gated here -- no machine runs one, and a target nobody uses is a constraint
# that only ever costs.
SIZES: tuple[tuple[int, int], ...] = ((1366, 768),)
# Long enough to be realistic: a message that wraps to three lines is a
# different height than one that does not, and this is what the phone check
# actually hands the retry screen.
LONG_MESSAGE = (
"✘ Workout JSON is from 2026-07-27, not today\n"
"Neither StrongLifts nor RunnerUp found a workout today.\n"
"Go do your workout first!"
)
class ScreenHost(
UIWidgetsMixin,
UIFlowsMixin,
UIFlowsRelaxedMixin,
SickDialogMixin,
ManualWorkoutDialogMixin,
):
"""The real screen-painting mixins, over a throwaway surface.
Only the attributes the paint methods read are set. Nothing that reaches a
phone, the network or a background thread is called: each screen is
painted directly, with the message its flow would have been handed.
The ``paint_*`` wrappers exist so the measurement list below never reaches
into the mixins' private surface from outside the class.
"""
def __init__(self, surface: ScrollableSurface) -> None:
"""Point the mixins at one surface's content frame."""
self.root = surface.content.winfo_toplevel()
self.demo_mode = False
self.verify_only = False
self.log_file = (
Path(__file__).resolve().parent.parent
/ "screen_locker"
/ "workout_log.json"
)
self._colors = LockConfig()
self.container = FrameGroup([surface.content])
self.lockout_time = 1800
self.workout_data: dict[str, str] = {}
# -- hooks the flows call on submit/cancel; no screen here is submitted --
def _on_manual_workout_saved(self, entry: dict[str, object]) -> None:
"""Unused: no form is submitted."""
def _on_manual_workout_cancelled(self) -> None:
"""Unused: BACK is never pressed."""
def unlock_screen(self) -> None:
"""Unused: nothing unlocks during a measurement."""
def close(self) -> None:
"""Unused: no screen is closed during a measurement."""
def _verify_phone_workout(self) -> tuple[str, str]:
"""Never reached -- present so a stray call fails loudly, not silently."""
msg = "the fit check must not run the phone check"
raise AssertionError(msg)
# -- public paint wrappers, one per screen under measurement --
def paint_retry(self) -> None:
"""Paint the "no workout found" retry screen."""
self._show_retry_and_sick(LONG_MESSAGE)
def paint_manual_form(self) -> None:
"""Paint the manual-workout evidence form."""
self._show_manual_workout_form()
def paint_manual_form_with_error(self) -> None:
"""Paint the form as it looks after a rejected submission.
Measured separately because the validation message is a line the empty
form does not have, and "fits until the user gets something wrong" is
not fitting.
"""
self._show_manual_workout_form()
self._mw_error_label.config(
text=(
"Start time must look like HH:MM, and every reflection needs "
f"at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} characters."
)
)
def paint_sick(self) -> None:
"""Paint the sick-day justification screen."""
self._show_sick_justification()
def paint_relaxed_retry(self) -> None:
"""Paint the relaxed-day (Tue-Thu) retry screen."""
self._show_relaxed_retry(LONG_MESSAGE, "no_workout")
def paint_verify_retry(self) -> None:
"""Paint the verify-mode retry screen."""
self._show_verify_retry(LONG_MESSAGE)
@dataclass(frozen=True)
class Screen:
"""One screen to measure, named by the paint call that produces it."""
name: str
paint: Callable[[ScreenHost], None]
def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc:
"""Paint the heat-skip screen and return the frame to measure.
Built through its extracted content builder because in production it owns
a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure.
"""
return build_heat_skip_content(
surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None
)
SCREENS: tuple[Screen, ...] = (
Screen("phone-check", ScreenHost.paint_phone_check),
Screen("no-workout-retry", ScreenHost.paint_retry),
Screen("manual-workout-form", ScreenHost.paint_manual_form),
Screen("manual-workout-form+error", ScreenHost.paint_manual_form_with_error),
Screen("sick-justification", ScreenHost.paint_sick),
Screen("lockout", ScreenHost.lockout),
Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry),
Screen("verify-retry", ScreenHost.paint_verify_retry),
)
# Screens built without a ScreenHost, measured through their own builder.
EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),)
def measure_all() -> list[FitResult]:
"""Measure every screen at every supported size."""
results: list[FitResult] = []
for width, height in SIZES:
for screen in SCREENS:
def build(surface: ScrollableSurface, paint: Screen = screen) -> None:
paint.paint(ScreenHost(surface))
results.append(measure_fit(screen.name, build, width=width, height=height))
results.extend(
measure_fit(name, build, width=width, height=height)
for name, build in EXTRA_SCREENS
)
return results
def main() -> int:
"""Measure every screen and fail if any of them overflows."""
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
if os.environ.get(INNER_ENV) != "1":
return reexec_under_xvfb(
inner_env=INNER_ENV, module="scripts.verify_screen_fits"
)
_logger.info("Measuring every lock screen against every supported panel:")
results = measure_all()
if report_fit(results):
_logger.info("\nOK: every screen fits without scrolling.")
return 0
_logger.error(
"\nAt least one screen needs a scrollbar to be usable. Shrink the "
"layout (design-system tokens, gatelock._density) rather than "
"accepting the scroll -- a lock screen the user has to scroll is the "
"defect this check exists to prevent."
)
return 1
if __name__ == "__main__":
sys.exit(main())