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
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:57:29 +02:00
parent 8bc981da4e
commit 76a4c6a0a7
16 changed files with 876 additions and 655 deletions

View File

@ -50,4 +50,6 @@ jobs:
pip install -e . --no-deps
- name: Sport selector survives gatelock's recovery tick
run: python3 scripts/verify_lock_popup_safety.py
# Run as a module (like the screen-fits gate) so scripts/ resolves as a
# package and the check can import the helpers it was split into.
run: python3 -m scripts.verify_lock_popup_safety

View File

@ -48,7 +48,9 @@ repos:
types: [python]
- id: requirements-sync
name: requirements.txt covers all package imports
entry: python3 scripts/check_requirements_sync.py
# scripts/ became a real package (it now holds shared helpers), so
# auto-detection sees two candidates -- name the one that ships.
entry: python3 scripts/check_requirements_sync.py --package screen_locker
language: system
pass_filenames: false
files: ^(screen_locker/.*\.py|requirements\.txt)$

View File

@ -0,0 +1,64 @@
"""Opt-in (non-autouse) fixtures for the screen_locker tests.
Split out of ``conftest.py`` to keep every file under the 250-line cap. These
are re-imported there, which is what keeps pytest discovering them by name --
a fixture is only visible to tests if it is reachable from ``conftest.py``.
Only fixtures a test must *request* live here. Every ``autouse`` fixture stays
in ``conftest.py``: pytest applies autouse fixtures from conftest only, so one
moved here would silently stop running and take its safety guard with it.
"""
from __future__ import annotations
from contextlib import ExitStack
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker.tests._tk_mocks import _TK_MODULES, _make_mock_tk
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
@pytest.fixture
def mock_tk() -> Generator[MagicMock]:
"""Mock the ``tkinter`` module across every UI module for display-free tests.
Patches the same single mock into all ``_TK_MODULES`` so assertions on
``mock_tk.Button`` capture widgets created by any of the split UI mixins
(``_ui_widgets``, ``_sick_dialog``, ...), not just ``screen_lock``.
"""
mock = _make_mock_tk()
with ExitStack() as stack:
for module in _TK_MODULES:
stack.enter_context(patch(f"{module}.tk", mock))
stack.enter_context(
patch(
"screen_locker.screen_lock.GateRoot",
return_value=mock.Tk.return_value,
)
)
yield mock
@pytest.fixture
def mock_sys_exit() -> Generator[MagicMock]:
"""Mock sys.exit to prevent test termination."""
with patch("screen_locker.screen_lock.sys.exit") as mock:
yield mock
@pytest.fixture
def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock:
"""Alias for mock_sys_exit when the return value is unused."""
return mock_sys_exit
@pytest.fixture
def temp_log_file(tmp_path: Path) -> Path:
"""Create a temporary log file path."""
return tmp_path / "workout_log.json"

View File

@ -0,0 +1,58 @@
"""The tkinter mock and the list of modules it must be patched into.
Split out of ``conftest.py`` to keep every file under the 250-line cap.
Only this scaffolding moved: every ``autouse`` fixture stays in ``conftest.py``,
because pytest collects autouse fixtures from conftest only -- one moved here
would simply stop running, and the suite would still pass while the guard it
provides was gone.
When a new module imports ``tkinter as tk`` and calls it, add it to
``_TK_MODULES`` below or its widgets are built on a real display.
"""
from __future__ import annotations
import tkinter as tk
from unittest.mock import MagicMock
# Every module that imports ``tkinter as tk`` and calls it directly. The UI is
# split across these, so each ``tk`` must be patched — so no test touches a real
# display and a ``mock_tk`` holder sees widgets made on that same mock.
_TK_MODULES = (
"screen_locker.screen_lock",
"screen_locker._sick_dialog",
"screen_locker._manual_workout_dialog",
"screen_locker._manual_workout_sport_fields",
"screen_locker._manual_workout_widgets",
"screen_locker._ui_form_fields",
"screen_locker._ui_widgets",
"screen_locker._window_setup",
"screen_locker.status_view",
"screen_locker._heat_skip",
"screen_locker._surface_group",
# The scroll viewport the lock surfaces are built from lives in gatelock
# and imports tkinter independently, so it needs the same mock -- otherwise
# container.first is a *real* tk.Frame whose winfo_children() cannot be
# stubbed, and every surface assertion breaks.
"gatelock._scrollable",
)
_VT_SHUTIL = "gatelock._vt.shutil"
_VT_SUBPROCESS = "gatelock._vt.subprocess"
def _make_mock_tk() -> MagicMock:
"""Build a MagicMock that stands in for the ``tkinter`` module."""
mock = MagicMock()
mock_root = MagicMock()
mock_root.winfo_screenwidth.return_value = 1920
mock_root.winfo_screenheight.return_value = 1080
mock.Tk.return_value = mock_root
mock_frame = MagicMock()
mock_frame.winfo_children.return_value = []
mock.Frame.return_value = mock_frame
# Keep real TclError so ``except tk.TclError`` still works.
mock.TclError = tk.TclError
return mock

View File

@ -14,7 +14,6 @@ from contextlib import ExitStack
from datetime import datetime, timezone
import json
from pathlib import Path
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -32,62 +31,39 @@ from screen_locker.tests._locker_factories import (
create_locker_early_bird,
create_locker_relaxed_day,
)
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
# Every module that imports ``tkinter as tk`` and calls it directly. The UI is
# split across these, so each ``tk`` must be patched — so no test touches a real
# display and a ``mock_tk`` holder sees widgets made on that same mock.
_TK_MODULES = (
"screen_locker.screen_lock",
"screen_locker._sick_dialog",
"screen_locker._manual_workout_dialog",
"screen_locker._manual_workout_sport_fields",
"screen_locker._manual_workout_widgets",
"screen_locker._ui_form_fields",
"screen_locker._ui_widgets",
"screen_locker._window_setup",
"screen_locker.status_view",
"screen_locker._heat_skip",
"screen_locker._surface_group",
# The scroll viewport the lock surfaces are built from lives in gatelock
# and imports tkinter independently, so it needs the same mock -- otherwise
# container.first is a *real* tk.Frame whose winfo_children() cannot be
# stubbed, and every surface assertion breaks.
"gatelock._scrollable",
from screen_locker.tests._opt_in_fixtures import (
_mock_sys_exit,
mock_sys_exit,
mock_tk,
temp_log_file,
)
from screen_locker.tests._tk_mocks import (
_TK_MODULES,
_VT_SHUTIL,
_VT_SUBPROCESS,
_make_mock_tk,
)
# Re-exported so pytest still resolves these by name: a fixture defined in a
# sibling module is only visible to tests once conftest imports it. __all__
# keeps ruff from pruning them as unused.
__all__ = [
"FAKE_OUTPUTS",
"TWO_OUTPUTS",
"_hermetic_gatelock",
"_make_locker",
"_mock_sys_exit",
"create_locker",
"create_locker_early_bird",
"create_locker_relaxed_day",
"dual_output",
"mock_sys_exit",
"mock_tk",
"temp_log_file",
]
_VT_SHUTIL = "gatelock._vt.shutil"
_VT_SUBPROCESS = "gatelock._vt.subprocess"
def _make_mock_tk() -> MagicMock:
"""Build a MagicMock that stands in for the ``tkinter`` module."""
mock = MagicMock()
mock_root = MagicMock()
mock_root.winfo_screenwidth.return_value = 1920
mock_root.winfo_screenheight.return_value = 1080
mock.Tk.return_value = mock_root
mock_frame = MagicMock()
mock_frame.winfo_children.return_value = []
mock.Frame.return_value = mock_frame
# Keep real TclError so ``except tk.TclError`` still works.
mock.TclError = tk.TclError
return mock
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
@pytest.fixture(autouse=True)
@ -152,55 +128,56 @@ def _block_real_network() -> Iterator[None]:
yield
@pytest.fixture(autouse=True)
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""
target = tmp_path / "sick_history.json"
with (
patch(
"screen_locker._sick_tracker.SICK_HISTORY_FILE",
target,
# Each on-disk state path, and every module that bound it by value at import
# time. All of them need patching, not just the _constants source -- a missed
# binding lets a test write to the real file.
_ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"sick_history.json",
("_sick_tracker.SICK_HISTORY_FILE", "_constants.SICK_HISTORY_FILE"),
),
patch(
"screen_locker._constants.SICK_HISTORY_FILE",
target,
(
"early_bird_pending.json",
(
"_early_bird.EARLY_BIRD_PENDING_FILE",
"_constants.EARLY_BIRD_PENDING_FILE",
),
):
yield
),
(
"extra_benefits_state.json",
(
"_constants.EXTRA_BENEFITS_FILE",
"_startup_checks.EXTRA_BENEFITS_FILE",
"_sync_mixin.EXTRA_BENEFITS_FILE",
"_early_bird.EXTRA_BENEFITS_FILE",
"_status.EXTRA_BENEFITS_FILE",
),
),
(
"sick_day_state.json",
(
"_constants.SICK_DAY_STATE_FILE",
"_startup_checks.SICK_DAY_STATE_FILE",
"_shutdown_sick_state.SICK_DAY_STATE_FILE",
),
),
("scheduled_skips.json", ("_log_mixin.SCHEDULED_SKIPS_FILE",)),
)
@pytest.fixture(autouse=True)
def _isolate_early_bird_pending(tmp_path: Path) -> Iterator[None]:
"""Redirect EARLY_BIRD_PENDING_FILE to tmp_path so tests use a clean file."""
target = tmp_path / "early_bird_pending.json"
with (
patch(
"screen_locker._early_bird.EARLY_BIRD_PENDING_FILE",
target,
),
patch(
"screen_locker._constants.EARLY_BIRD_PENDING_FILE",
target,
),
):
yield
def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
"""Redirect every on-disk state path to tmp_path for every test.
@pytest.fixture(autouse=True)
def _isolate_extra_benefits(tmp_path: Path) -> Iterator[None]:
"""Redirect EXTRA_BENEFITS_FILE to tmp_path so tests cannot touch real state.
Bound by value into several modules at import time, so every bound name
needs patching individually — not just the ``_constants`` source.
Stays in conftest.py and stays autouse: pytest only applies autouse
fixtures declared here, so moving this to a sibling module would leave
tests writing to the user's real state files while still passing.
"""
target = tmp_path / "extra_benefits_state.json"
with (
patch("screen_locker._constants.EXTRA_BENEFITS_FILE", target),
patch("screen_locker._startup_checks.EXTRA_BENEFITS_FILE", target),
patch("screen_locker._sync_mixin.EXTRA_BENEFITS_FILE", target),
patch("screen_locker._early_bird.EXTRA_BENEFITS_FILE", target),
patch("screen_locker._status.EXTRA_BENEFITS_FILE", target),
):
with ExitStack() as stack:
for filename, bindings in _ISOLATED_STATE:
target = tmp_path / filename
for binding in bindings:
stack.enter_context(patch(f"screen_locker.{binding}", target))
yield
@ -227,28 +204,6 @@ def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
yield
@pytest.fixture(autouse=True)
def _isolate_sick_day_state(tmp_path: Path) -> Iterator[None]:
"""Redirect SICK_DAY_STATE_FILE to tmp_path so tests cannot touch real state."""
target = tmp_path / "sick_day_state.json"
with (
patch("screen_locker._constants.SICK_DAY_STATE_FILE", target),
patch("screen_locker._startup_checks.SICK_DAY_STATE_FILE", target),
):
yield
@pytest.fixture(autouse=True)
def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
"""Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file."""
target = tmp_path / "scheduled_skips.json"
with patch(
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
target,
):
yield
@pytest.fixture(autouse=True)
def _mock_weekly_logic() -> Iterator[None]:
"""Default to Fri-Mon enforcement with weekly minimum not yet met.
@ -271,46 +226,6 @@ def _mock_weekly_logic() -> Iterator[None]:
yield
@pytest.fixture
def mock_tk() -> Generator[MagicMock]:
"""Mock the ``tkinter`` module across every UI module for display-free tests.
Patches the same single mock into all ``_TK_MODULES`` so assertions on
``mock_tk.Button`` capture widgets created by any of the split UI mixins
(``_ui_widgets``, ``_sick_dialog``, ...), not just ``screen_lock``.
"""
mock = _make_mock_tk()
with ExitStack() as stack:
for module in _TK_MODULES:
stack.enter_context(patch(f"{module}.tk", mock))
stack.enter_context(
patch(
"screen_locker.screen_lock.GateRoot",
return_value=mock.Tk.return_value,
)
)
yield mock
@pytest.fixture
def mock_sys_exit() -> Generator[MagicMock]:
"""Mock sys.exit to prevent test termination."""
with patch("screen_locker.screen_lock.sys.exit") as mock:
yield mock
@pytest.fixture
def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock:
"""Alias for mock_sys_exit when the return value is unused."""
return mock_sys_exit
@pytest.fixture
def temp_log_file(tmp_path: Path) -> Path:
"""Create a temporary log file path."""
return tmp_path / "workout_log.json"
@pytest.fixture(autouse=True)
def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Point the Firebase config at a path that does not exist.

View File

@ -171,88 +171,3 @@ class TestApplyWorkoutCredit:
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_workout_unreadable_shutdown_config_earns_no_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Additional same-day workout, but the shutdown config can't be read
(old_cfg is None) → the +1h branch is entered but bails out before
ever attempting the adjustment."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_workout_credit()
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_additional_workout_of_uncounted_type_earns_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A workout whose type isn't in COUNTED_WORKOUT_TYPES (e.g.
``early_bird``) and isn't the first counted entry of the day →
neither the base push nor the +1h branch fires; the shutdown config
is never even consulted."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "early_bird"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_credit_for_written_entry(prior)
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._read_shutdown_config.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_skips_save_for_sick_day_type(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Skips save for sick day type."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(locker, "save_workout_log", MagicMock())
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is False
locker.save_workout_log.assert_not_called()

View File

@ -0,0 +1,117 @@
"""More workout-credit cases: bonuses, unreadable config, sick days.
Split out of test_manual_workout_features_part2.py for the 250-line cap.
"""
# pylint: disable=protected-access
from __future__ import annotations
import pathlib
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker._log_mixin import RecordResult
from screen_locker.tests.conftest import create_locker
# The Dart mirror of the budget caps; read at test time so drift on
# EITHER side fails, not just a local edit to the Python constants.
DART_MANUAL_WORKOUT = (
pathlib.Path(__file__).resolve().parents[2]
/ "stronglift_replacement"
/ "workout_app"
/ "lib"
/ "models"
/ "manual_workout.dart"
)
if TYPE_CHECKING:
from pathlib import Path
class TestApplyWorkoutCredit:
"""More _apply_workout_credit cases under the multi-per-day rule."""
def test_additional_workout_unreadable_shutdown_config_earns_no_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Additional same-day workout, but the shutdown config can't be read
(old_cfg is None) → the +1h branch is entered but bails out before
ever attempting the adjustment."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_workout_credit()
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_additional_workout_of_uncounted_type_earns_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A workout whose type isn't in COUNTED_WORKOUT_TYPES (e.g.
``early_bird``) and isn't the first counted entry of the day →
neither the base push nor the +1h branch fires; the shutdown config
is never even consulted."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "early_bird"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_credit_for_written_entry(prior)
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._read_shutdown_config.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_skips_save_for_sick_day_type(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Skips save for sick day type."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(locker, "save_workout_log", MagicMock())
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is False
locker.save_workout_log.assert_not_called()

View File

@ -5,7 +5,6 @@ Split out of test_runnerup_verification_part7.py for the 250-line cap.
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -227,69 +226,3 @@ class TestScanAndFillWeekRunnerupSkips:
# The append still reports success; only the save failure is warned.
assert result >= 1
def test_handles_corrupt_log_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Corrupt log JSON → starts with empty dict, still works."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("not-json")
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
# Should not raise; returns 0 (no exports found).
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_hmac_none_still_fills_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""HMAC key absent (compute_entry_hmac returns None) → entry still written."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("{}")
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
object.__setattr__(
locker,
"_validate_runnerup_data",
MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")),
)
with patch(
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
):
result = locker._scan_and_fill_week_runnerup(log_file)
assert result > 0
# No "hmac" key when signature is None; each day holds a list of entries.
logs = json.loads(log_file.read_text())
for entries in logs.values():
for entry in entries:
assert "hmac" not in entry
# ---------------------------------------------------------------------------
# _find_runnerup_package
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,137 @@
"""More RunnerUp week-scan backfill edge cases.
Split out of test_runnerup_verification_part10.py for the 250-line cap.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
class TestScanAndFillWeekRunnerupSkips:
"""Days the scan declines to fill, and failures it survives."""
def test_handles_corrupt_log_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Corrupt log JSON → starts with empty dict, still works."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("not-json")
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
# Should not raise; returns 0 (no exports found).
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_hmac_none_still_fills_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""HMAC key absent (compute_entry_hmac returns None) → entry still written."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("{}")
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
object.__setattr__(
locker,
"_validate_runnerup_data",
MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")),
)
with patch(
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
):
result = locker._scan_and_fill_week_runnerup(log_file)
assert result > 0
# No "hmac" key when signature is None; each day holds a list of entries.
logs = json.loads(log_file.read_text())
for entries in logs.values():
for entry in entries:
assert "hmac" not in entry
# ---------------------------------------------------------------------------
# _find_runnerup_package
# ---------------------------------------------------------------------------

View File

@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests._unlock_helpers import setup_unlock
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
@ -131,122 +130,3 @@ class TestUnlockScreenExtras:
locker.unlock_screen()
assert not any("Extra workout" in c for c in text_calls)
class TestIngestSyncedManualWorkouts:
"""Tests for _ingest_synced_manual_workouts (manual-sync wiring)."""
def test_logs_each_ingested_record(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Logs each ingested record."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[("manual:x", {})],
),
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=["manual:x"],
) as ingest,
):
locker._ingest_synced_manual_workouts()
ingest.assert_called_once()
def test_no_records_ingests_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""No records ingests nothing."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[],
),
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=[],
),
):
locker._ingest_synced_manual_workouts()
def test_passes_credit_callback_and_resets_workout_data(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The callback wired into ingest_manual_records must be
``locker._credit_ingested_manual_workout``, so a synced manual
workout earns the same reward a live one would; workout_data is reset
afterward so it can't leak into a later interactive flow."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"stale": "state"}
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[],
),
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=[],
) as ingest,
):
locker._ingest_synced_manual_workouts()
assert ingest.call_args.kwargs["on_ingested"] == (
locker._credit_ingested_manual_workout
)
assert locker.workout_data == {}
def test_credit_ingested_manual_workout_applies_reward(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""_credit_ingested_manual_workout sets workout_data to the ingested
entry and applies the shared credit logic — proving a synced manual
workout (today's or back-dated) earns full shutdown/debt credit,
exactly like a live-logged one."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "table tennis at Solec"}
prior: list[dict] = []
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=True, extra_bonus_delta=0),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)
def test_credit_ingested_manual_workout_logs_extra_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A second same-day synced manual workout (extra_bonus_delta, not the
base push) takes the elif branch — a back-dated sync can still stack
the +1h bonus exactly like an additional same-day verified workout."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "gym"}
prior = [{"workout_data": {"type": "manual_workout"}}]
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=1),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)
def test_credit_ingested_manual_workout_no_reward_logs_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A duplicate/no-op credit result (neither shutdown_adjusted nor
extra_bonus_delta) → neither log line fires; credit is still applied
via workout_data being set."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "gym"}
prior: list[dict] = []
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=0),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)

View File

@ -0,0 +1,133 @@
"""Tests for ingesting manual workouts synced from the phone.
Split out of test_screen_lock_coverage_part2.py for the 250-line cap.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestIngestSyncedManualWorkouts:
"""Tests for _ingest_synced_manual_workouts (manual-sync wiring)."""
def test_logs_each_ingested_record(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Logs each ingested record."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[("manual:x", {})],
),
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=["manual:x"],
) as ingest,
):
locker._ingest_synced_manual_workouts()
ingest.assert_called_once()
def test_no_records_ingests_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""No records ingests nothing."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[],
),
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=[],
),
):
locker._ingest_synced_manual_workouts()
def test_passes_credit_callback_and_resets_workout_data(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The callback wired into ingest_manual_records must be
``locker._credit_ingested_manual_workout``, so a synced manual
workout earns the same reward a live one would; workout_data is reset
afterward so it can't leak into a later interactive flow."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"stale": "state"}
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[],
),
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=[],
) as ingest,
):
locker._ingest_synced_manual_workouts()
assert ingest.call_args.kwargs["on_ingested"] == (
locker._credit_ingested_manual_workout
)
assert locker.workout_data == {}
def test_credit_ingested_manual_workout_applies_reward(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""_credit_ingested_manual_workout sets workout_data to the ingested
entry and applies the shared credit logic — proving a synced manual
workout (today's or back-dated) earns full shutdown/debt credit,
exactly like a live-logged one."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "table tennis at Solec"}
prior: list[dict] = []
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=True, extra_bonus_delta=0),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)
def test_credit_ingested_manual_workout_logs_extra_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A second same-day synced manual workout (extra_bonus_delta, not the
base push) takes the elif branch — a back-dated sync can still stack
the +1h bonus exactly like an additional same-day verified workout."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "gym"}
prior = [{"workout_data": {"type": "manual_workout"}}]
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=1),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)
def test_credit_ingested_manual_workout_no_reward_logs_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A duplicate/no-op credit result (neither shutdown_adjusted nor
extra_bonus_delta) → neither log line fires; credit is still applied
via workout_data being set."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "gym"}
prior: list[dict] = []
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=0),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)

15
scripts/__init__.py Normal file
View File

@ -0,0 +1,15 @@
"""Maintenance and verification scripts for screen-locker.
A real package rather than a loose directory, so the shared helpers split out
of the verification gates (``_xvfb_reexec``, ``_popup_form_check``) are
importable as ``scripts.<name>``.
Both invocation styles still work and both are used:
* ``python3 -m scripts.verify_screen_fits`` -- pre-commit
* ``python3 scripts/verify_lock_popup_safety.py`` -- CI
The second puts ``scripts/`` itself on ``sys.path`` instead of the repo root,
so the gates that import a sibling try the package-qualified name first and
fall back to the bare one.
"""

View File

@ -0,0 +1,194 @@
"""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
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from gatelock import LockConfig, RecoveryLoop
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.
self._loop = RecoveryLoop(
root, LockConfig(mode="hard"), MagicMock(), MagicMock(), 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
self.log_file = (
Path(__file__).resolve().parent.parent
/ "screen_locker"
/ "workout_log.json"
)
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)

72
scripts/_xvfb_reexec.py Normal file
View File

@ -0,0 +1,72 @@
"""Re-run a GUI check script on a throwaway X display.
Split out of ``verify_lock_popup_safety.py`` and ``verify_screen_fits.py``
to keep every file under the 250-line cap. Both had their own near-identical
copy; they differ only in the sentinel env var and how the inner process is
addressed, so both are parameters here.
Each script runs itself twice: the outer run starts ``xvfb-run`` and re-execs,
the inner run (marked by the sentinel) does the real work against that
display. Without the sentinel the inner process would start another Xvfb
forever.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import shutil
import subprocess
import sys
_logger = logging.getLogger(__name__)
# Comfortably bigger than every size under test, so each surface is sized by
# the geometry the harness sets rather than clamped by the display.
_SCREEN_ARG = "-screen 0 1600x1200x24"
def reexec_under_xvfb(
*,
inner_env: str,
module: str | None = None,
script: Path | None = None,
) -> int:
"""Re-run this check on a throwaway X display, returning its exit code.
Exactly one of *module* (run as ``python -m module`` from the repo root)
or *script* (run as an absolute path) identifies the inner process; that
difference is why the two callers cannot share a single hardcoded form.
Args:
inner_env: Sentinel env var set for the inner run, so it knows not to
start another Xvfb.
module: Dotted module path to run with ``-m``.
script: Absolute path of the script file to run.
Returns:
The inner process's exit code, or 1 if ``xvfb-run`` is unavailable.
"""
if (module is None) == (script is None):
msg = "pass exactly one of module= or script="
raise ValueError(msg)
xvfb_run = shutil.which("xvfb-run")
if xvfb_run is None:
_logger.error(
"xvfb-run not found. Install it with: sudo pacman -S --needed "
"xorg-server-xvfb"
)
return 1
env = dict(os.environ, **{inner_env: "1"})
repo_root = Path(__file__).resolve().parent.parent
# Absolute argv, no shell.
target = ["-m", module] if module is not None else [str(script)]
return subprocess.run(
[xvfb_run, "-a", "-s", _SCREEN_ARG, sys.executable, *target],
check=False,
cwd=repo_root,
env=env,
).returncode

View File

@ -22,30 +22,25 @@ 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 ~/screen-locker/scripts/verify_lock_popup_safety.py
python3 -m scripts.verify_lock_popup_safety # from the repo root
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from gatelock import LockConfig, RecoveryLoop
from gatelock import RecoveryLoop
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
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__)
@ -54,105 +49,6 @@ TICKS = 5
INNER_ENV = "_LOCK_POPUP_CHECK_INNER"
def reexec_under_xvfb() -> int:
"""Re-run this script on a throwaway X display, returning its exit code."""
xvfb_run = shutil.which("xvfb-run")
if xvfb_run is None:
_logger.error(
"xvfb-run not found. Install it with: sudo pacman -S --needed "
"xorg-server-xvfb"
)
return 1
env = dict(os.environ, **{INNER_ENV: "1"})
# Absolute argv, no shell.
return subprocess.run(
[
xvfb_run,
"-a",
"-s",
"-screen 0 1600x1200x24",
sys.executable,
str(Path(__file__).resolve()),
],
check=False,
env=env,
).returncode
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.
self._loop = RecoveryLoop(
root, LockConfig(mode="hard"), MagicMock(), MagicMock(), 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))
def check_optionmenu_still_fails() -> bool:
"""Show the original mechanism: a posted menu gets covered by the lift."""
root = tk.Tk()
@ -187,98 +83,13 @@ def check_optionmenu_still_fails() -> bool:
return run_ticks(root, judge, setup=open_menu)
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
self.log_file = (
Path(__file__).resolve().parent.parent
/ "screen_locker"
/ "workout_log.json"
)
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)
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()
return reexec_under_xvfb(
inner_env=INNER_ENV, module="scripts.verify_lock_popup_safety"
)
if not hasattr(RecoveryLoop, "holds_grab"):
_logger.error(

View File

@ -32,8 +32,6 @@ from dataclasses import dataclass
import logging
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING
@ -47,6 +45,7 @@ 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
@ -74,34 +73,6 @@ LONG_MESSAGE = (
)
def reexec_under_xvfb() -> int:
"""Re-run this script on a throwaway X display, returning its exit code."""
xvfb_run = shutil.which("xvfb-run")
if xvfb_run is None:
_logger.error(
"xvfb-run not found. Install it with: sudo pacman -S --needed "
"xorg-server-xvfb"
)
return 1
env = dict(os.environ, **{INNER_ENV: "1"})
return subprocess.run(
[
xvfb_run,
"-a",
# Bigger than every size under test, so each surface is sized by
# the geometry this harness sets rather than clamped by the display.
"-s",
"-screen 0 1600x1200x24",
sys.executable,
"-m",
"scripts.verify_screen_fits",
],
check=False,
cwd=Path(__file__).resolve().parent.parent,
env=env,
).returncode
class ScreenHost(
UIWidgetsMixin,
UIFlowsMixin,
@ -246,7 +217,9 @@ 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()
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()