screen-locker/scripts/_popup_form_check.py
Krzysztof kuhy Rudnicki d8bb8943eb
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Fix the popup-safety check: pinned gatelock API, and its own empty log
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:53:18 +02:00

218 lines
7.7 KiB
Python

"""The real manual-workout form, checked for popups, plus the tick helpers.
Split out of ``verify_lock_popup_safety.py`` to keep every file under the
250-line cap. That script imports ``check_real_form_survives`` and runs it as
one of its two checks.
The point of the check: the evidence form must build every widget inline on
the lock surface. A widget that opens its own toplevel (an OptionMenu, a
messagebox) would appear *above* the lock and hand back an escape hatch.
"""
from __future__ import annotations
import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from gatelock import LockConfig, RecoveryLoop
# Not re-exported at the package top level, but it is the only way to
# build a RecoveryLoop on the pinned version. This harness already
# borrows gatelock's real grab predicate on purpose, so reaching for the
# collaborators type it requires is the same deliberate coupling.
from gatelock._recovery import RecoveryCollaborators
from screen_locker import _manual_workout
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
from screen_locker._surface_group import FrameGroup
from screen_locker._ui_widgets import UIWidgetsMixin
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
TICK_MS = 1000
TICKS = 5
class HostileLock:
"""gatelock's two recovery-tick behaviours over a real Tk root."""
def __init__(self, root: tk.Tk) -> None:
"""Take the global grab and start ticking."""
self.root = root
self.ticks = 0
# A RecoveryLoop built only to borrow gatelock's real grab predicate;
# this harness drives the ticks itself, so the collaborators are never
# called -- the same MagicMock stand-ins gatelock's own tests use.
#
# Matches the RecoveryCollaborators signature that requirements.txt
# actually pins (gatelock-v0.7.1). The old five-positional call was
# gatelock 0.4.0's, and the comment here claimed that was the pin --
# it had not been true for several releases, so this failed in CI on
# every push while reading as "environment drift, not a bug here".
self._loop = RecoveryLoop(
root,
RecoveryCollaborators(
config=LockConfig(mode="hard"),
surfaces=MagicMock(),
enumerator=MagicMock(),
detector=MagicMock(),
hooks=MagicMock(),
),
)
root.grab_set_global()
root.after(TICK_MS, self._tick)
def _tick(self) -> None:
"""Lift the surface and re-take the grab, exactly as gatelock does."""
self.root.lift()
if not self._loop.holds_grab():
self.root.grab_set_global()
# Counted only after the work, so a raising tick does not count. Tk
# swallows exceptions from `after` callbacks, and this harness once
# reported OK while every tick was dying on an AttributeError.
self.ticks += 1
self.root.after(TICK_MS, self._tick)
def run_ticks(
root: tk.Tk, judge: Callable[[], bool], setup: Callable[[], None] | None = None
) -> bool:
"""Tick hostilely TICKS times, then let ``judge`` say what survived.
``setup`` runs AFTER the lock has taken its grab, which is the order the
real thing has: the user opens a widget on an already-grabbed lock. Posting
a menu first would just let the lock's initial ``grab_set_global()`` take
it, proving nothing about the recovery tick.
"""
verdict = False
def decide() -> None:
nonlocal verdict
verdict = judge()
lock = HostileLock(root)
if setup is not None:
root.after(200, setup)
root.after(TICK_MS * TICKS + 400, decide)
root.after(TICK_MS * TICKS + 600, root.quit)
root.mainloop()
root.destroy()
if lock.ticks < TICKS:
_logger.error(
" only %d of %d hostile ticks completed -- the harness itself is "
"broken, so this result proves nothing",
lock.ticks,
TICKS,
)
return False
return verdict
def leaf(name: object) -> str:
"""A Tk window path shortened to its last component, "root" for ``.``."""
if not name:
return "<nothing>"
return str(name).rsplit(".", 1)[-1] or "root"
def grab_holder(root: tk.Tk) -> str:
"""The leaf name of whatever currently holds the grab."""
return leaf(root.tk.call("grab", "current", root))
class FormHost(UIWidgetsMixin, ManualWorkoutDialogMixin):
"""The two hooks the mixin requires, over a real Tk root."""
def __init__(self, root: tk.Tk) -> None:
"""Set only the attributes the form actually reads."""
self.root = root
self.demo_mode = False
# An empty throwaway log, NOT the real one. The form renders a
# "budget exhausted" note instead of the sport selector once the
# week's manual workouts are used up, so pointing at the live log made
# this check pass or fail on how much the user had exercised that
# week rather than on whether the widgets survive a recovery tick.
self._log_dir = TemporaryDirectory()
self.log_file = Path(self._log_dir.name) / "log.json"
self.log_file.write_text("{}")
self._colors = LockConfig()
self.container = FrameGroup.single(root, bg=self._colors.bg)
def _on_manual_workout_saved(self, entry: dict[str, object]) -> None:
"""Unused here; the form is never submitted."""
def _on_manual_workout_cancelled(self) -> None:
"""Unused here; BACK is never pressed."""
# Public wrappers so this script never reaches into the mixin's
# private surface from outside the class.
def render(self) -> None:
"""Paint the manual-workout form into the container."""
self._show_manual_workout_form()
def state(self) -> tuple[str, bool, bool]:
"""(selected sport code, other fields shown, table-tennis shown)."""
return (
self._current_mw_sport(),
bool(self._mw_other_frame.winfo_ismapped()),
bool(self._mw_tt_frame.winfo_ismapped()),
)
def radios(widget: tk.Misc) -> list[tk.Radiobutton]:
"""Every Radiobutton under ``widget``, depth-first."""
found: list[tk.Radiobutton] = []
for child in widget.winfo_children():
if isinstance(child, tk.Radiobutton):
found.append(child)
found.extend(radios(child))
return found
def check_real_form_survives() -> bool:
"""Build the production form, pick "Other", hold it through the ticks."""
root = tk.Tk()
root.geometry("1600x1200+0+0")
host = FormHost(root)
host.render()
root.update()
buttons = radios(root)
labels = [b.cget("text") for b in buttons]
_logger.info(" sport radio buttons rendered: %s", labels)
if len(buttons) != len(_manual_workout.SPORT_LABELS):
_logger.error(
" expected one radio button per sport (%d), got %d",
len(_manual_workout.SPORT_LABELS),
len(labels),
)
root.destroy()
return False
def pick_other() -> None:
buttons[1].invoke()
root.update()
def judge() -> bool:
"""The Other sport must still be selected, with its own fields shown."""
sport, other_shown, tt_shown = host.state()
_logger.info(
" radio buttons after %d ticks: grab=%s sport=%r other_fields=%s "
"tt_fields=%s",
TICKS,
grab_holder(root),
sport,
other_shown,
tt_shown,
)
return sport == _manual_workout.SPORT_OTHER and other_shown and not tt_shown
return run_ticks(root, judge, setup=pick_other)