screen-locker/scripts/verify_screen_fits.py
Krzysztof kuhy Rudnicki e5bfdd9a9a Make the Tk locker screens text-free
Every locker screen, the status window and the credit toast now show
glyphs (screen_locker/_glyphs.py) and numbers, coloured by state.
Sentences that carried a reason (verifier message, sync diagnosis,
sick refusal, form rejection) go to the log at warning instead.
New pre-commit hook text-free runs scripts/verify_text_free.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:20:23 +01:00

231 lines
8.6 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 absent: it is a resizable window, not a lock surface.
Not a pytest test: conftest replaces ``tk`` so no test reaches a display, and
heights need real rendering. It re-executes itself under Xvfb:
cd ~/src/screen-locker && python3 -m scripts.verify_screen_fits
Run as a *module*: ``-m`` puts the repo root on ``sys.path``, so
``screen_locker`` resolves from the checkout (as a path it 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._manual_workout_date import REJECTED_GLYPH
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
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._fit_extra_screens import EXTRA_SCREENS
from scripts._xvfb_reexec import reexec_under_xvfb
if TYPE_CHECKING:
from collections.abc import Callable
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!"
)
# sickdays' FreeDayConflictError text: its longest refusal.
SICK_REFUSAL = (
"2026-10-16 is a free day -- release it with 'freedays release' before "
"marking it sick"
)
class ScreenHost(
UIWidgetsMixin,
UIFlowsMixin,
UIFlowsRelaxedMixin,
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._relaxed_day_mode = False
# Deliberately NOT the real log.json: is_budget_exhausted() reads it,
# and the real file's content depends on today's actual usage --
# exhausting the manual-workout budget in production made this crash
# (paint_manual_form_with_error touches _mw_error_label, which the
# budget-exhausted branch of _show_manual_workout_form never sets).
# A path that can never exist keeps every measured screen's shape
# independent of live data, which is the same guarantee every other
# attribute on this class already gives (see class docstring).
self.log_file = (
Path(__file__).resolve().parent.parent
/ "screen_locker"
/ "fit_check_never_exists.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=REJECTED_GLYPH)
def paint_sick_refused(self) -> None:
"""Paint the screen shown when today cannot be marked sick."""
self._show_sick_refused(SICK_REFUSAL)
def paint_phone_penalty(self) -> None:
"""Paint the no-phone penalty screen (countdown + I'm sick)."""
self._show_phone_penalty(LONG_MESSAGE)
def paint_relaxed_prompt(self) -> None:
"""Paint the optional Tue-Thu prompt (Skip / Log / I'm sick)."""
self._start_relaxed_day_flow()
def paint_relaxed_retry(self) -> None:
"""Paint the relaxed-day (Tue-Thu) retry screen."""
self._show_relaxed_retry(LONG_MESSAGE, "no_workout")
@dataclass(frozen=True)
class Screen:
"""One screen to measure, named by the paint call that produces it."""
name: str
paint: Callable[[ScreenHost], 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-refused", ScreenHost.paint_sick_refused),
Screen("phone-penalty", ScreenHost.paint_phone_penalty),
Screen("relaxed-day-prompt", ScreenHost.paint_relaxed_prompt),
Screen("lockout", ScreenHost.lockout),
Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry),
)
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())