screen-locker/scripts/verify_lock_popup_safety.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

130 lines
4.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prove the lock-screen sport selector survives gatelock's recovery tick.
``_manual_workout_dialog._mw_sport_row`` has the mechanism and the incident.
``screen_locker/tests/test_no_popup_widgets.py`` keeps popups out of the lock
path statically; this script is the *behavioural* half. It builds the real
``ManualWorkoutDialogMixin`` form with real Tk, runs gatelock's two hostile
behaviours for five ticks with the "Other" sport selected, and checks the
selection and the swapped-in fields are still there. It also re-runs the
original failure against a ``tk.OptionMenu``, so the mechanism stays
demonstrated rather than merely described -- if that check ever stops
reproducing, this harness has drifted from what gatelock actually does.
Fidelity gap, deliberate: ``HostileLock`` replays the two behaviours rather
than calling ``RecoveryLoop.tick()``, which would need a real ``SurfaceSet``
managing real per-output windows on this throwaway display. It borrows the
real ``holds_grab()`` so the predicate cannot drift; a THIRD hostile behaviour
added to ``tick()`` would not be covered 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 real
display. This script re-executes itself under Xvfb instead, so a global grab
can never freeze the developer's own session. Run it directly, or let CI:
python3 -m scripts.verify_lock_popup_safety # from the repo root
"""
from __future__ import annotations
import logging
import os
import sys
import tkinter as tk
from gatelock import RecoveryLoop
from scripts._popup_form_check import (
check_real_form_survives,
grab_holder,
leaf,
run_ticks,
)
from scripts._xvfb_reexec import reexec_under_xvfb
_logger = logging.getLogger(__name__)
TICK_MS = 1000
TICKS = 5
INNER_ENV = "_LOCK_POPUP_CHECK_INNER"
def check_optionmenu_still_fails() -> bool:
"""Show the original mechanism: a posted menu gets covered by the lift."""
root = tk.Tk()
root.geometry("800x600+0+0")
surface = tk.Toplevel(root)
surface.overrideredirect(boolean=True)
surface.geometry("800x600+0+0")
var = tk.StringVar(value="Table tennis")
option = tk.OptionMenu(surface, var, "Table tennis", "Other")
option.pack()
root.update()
menu = surface.nametowidget(option["menu"])
def open_menu() -> None:
menu.tk_popup(100, 100)
root.update()
def judge() -> bool:
"""A click where the menu appears must no longer reach the menu."""
centre_x = menu.winfo_rootx() + menu.winfo_width() // 2
centre_y = menu.winfo_rooty() + menu.winfo_height() // 2
hit = root.winfo_containing(centre_x, centre_y)
_logger.info(
" OptionMenu after %d ticks: grab=%s click_lands_on=%s",
TICKS,
grab_holder(root),
leaf(hit),
)
return hit is not menu
return run_ticks(root, judge, setup=open_menu)
def main() -> int:
"""Run both checks under Xvfb and report which held."""
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_lock_popup_safety"
)
if not hasattr(RecoveryLoop, "holds_grab"):
_logger.error(
"The installed gatelock has no RecoveryLoop.holds_grab, so this "
"check cannot replay the real grab predicate. Bump the gatelock "
"pin in requirements.txt to a version that has it."
)
return 1
_logger.info("The mechanism (a popup on a lock surface):")
mechanism_reproduced = check_optionmenu_still_fails()
_logger.info("The fix (radio buttons in the production form):")
fix_holds = check_real_form_survives()
if not mechanism_reproduced:
_logger.error(
"The OptionMenu was NOT covered by the recovery tick. This check no "
"longer reproduces the failure it exists to guard against -- either "
"gatelock stopped lifting its surfaces, or this harness has drifted."
)
if not fix_holds:
_logger.error(
"The production sport selector did NOT survive %d recovery ticks. "
"The 2026-07-26 lock-screen bug is back.",
TICKS,
)
if mechanism_reproduced and fix_holds:
_logger.info(
"\nOK: a popup still dies under the tick; the shipped radio-button "
"selector survives it."
)
return 0
return 1
if __name__ == "__main__":
sys.exit(main())