diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 5bcd0fe..9bb70ed 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -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
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 32fc2a3..af6ba93 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -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)$
diff --git a/screen_locker/tests/_opt_in_fixtures.py b/screen_locker/tests/_opt_in_fixtures.py
new file mode 100644
index 0000000..76e45ed
--- /dev/null
+++ b/screen_locker/tests/_opt_in_fixtures.py
@@ -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"
diff --git a/screen_locker/tests/_tk_mocks.py b/screen_locker/tests/_tk_mocks.py
new file mode 100644
index 0000000..f003af0
--- /dev/null
+++ b/screen_locker/tests/_tk_mocks.py
@@ -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
diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py
index d7806fd..1b6b77c 100644
--- a/screen_locker/tests/conftest.py
+++ b/screen_locker/tests/conftest.py
@@ -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"),
+ ),
+ (
+ "early_bird_pending.json",
+ (
+ "_early_bird.EARLY_BIRD_PENDING_FILE",
+ "_constants.EARLY_BIRD_PENDING_FILE",
),
- patch(
- "screen_locker._constants.SICK_HISTORY_FILE",
- target,
+ ),
+ (
+ "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",
),
- ):
- yield
+ ),
+ (
+ "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.
diff --git a/screen_locker/tests/test_manual_workout_features_part2.py b/screen_locker/tests/test_manual_workout_features_part2.py
index 1f8ca17..9223cfd 100644
--- a/screen_locker/tests/test_manual_workout_features_part2.py
+++ b/screen_locker/tests/test_manual_workout_features_part2.py
@@ -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()
diff --git a/screen_locker/tests/test_manual_workout_features_part4.py b/screen_locker/tests/test_manual_workout_features_part4.py
new file mode 100644
index 0000000..c0506d8
--- /dev/null
+++ b/screen_locker/tests/test_manual_workout_features_part4.py
@@ -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()
diff --git a/screen_locker/tests/test_runnerup_verification_part10.py b/screen_locker/tests/test_runnerup_verification_part10.py
index 79cf36c..99bfb15 100644
--- a/screen_locker/tests/test_runnerup_verification_part10.py
+++ b/screen_locker/tests/test_runnerup_verification_part10.py
@@ -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
-# ---------------------------------------------------------------------------
diff --git a/screen_locker/tests/test_runnerup_verification_part15.py b/screen_locker/tests/test_runnerup_verification_part15.py
new file mode 100644
index 0000000..5593859
--- /dev/null
+++ b/screen_locker/tests/test_runnerup_verification_part15.py
@@ -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 = """\
+
+
+
+
+
+ 2400.0
+ 6000.0
+
+
+
+
+"""
+
+# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
+_TCX_GYM = """\
+
+
+
+
+
+ 3600.0
+ 0.0
+
+
+
+
+"""
+
+# Two laps that together make a valid run.
+_TCX_MULTI_LAP = """\
+
+
+
+
+
+ 1200.0
+ 3000.0
+
+
+ 1200.0
+ 3000.0
+
+
+
+
+"""
+
+
+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
+# ---------------------------------------------------------------------------
diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py
index fe80370..b894975 100644
--- a/screen_locker/tests/test_screen_lock_coverage_part2.py
+++ b/screen_locker/tests/test_screen_lock_coverage_part2.py
@@ -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)
diff --git a/screen_locker/tests/test_screen_lock_coverage_part5.py b/screen_locker/tests/test_screen_lock_coverage_part5.py
new file mode 100644
index 0000000..1925e9d
--- /dev/null
+++ b/screen_locker/tests/test_screen_lock_coverage_part5.py
@@ -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)
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..508fcd1
--- /dev/null
+++ b/scripts/__init__.py
@@ -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.``.
+
+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.
+"""
diff --git a/scripts/_popup_form_check.py b/scripts/_popup_form_check.py
new file mode 100644
index 0000000..7a3d8c0
--- /dev/null
+++ b/scripts/_popup_form_check.py
@@ -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 ""
+ 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)
diff --git a/scripts/_xvfb_reexec.py b/scripts/_xvfb_reexec.py
new file mode 100644
index 0000000..34a17c2
--- /dev/null
+++ b/scripts/_xvfb_reexec.py
@@ -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
diff --git a/scripts/verify_lock_popup_safety.py b/scripts/verify_lock_popup_safety.py
index 2a22e46..c817f73 100755
--- a/scripts/verify_lock_popup_safety.py
+++ b/scripts/verify_lock_popup_safety.py
@@ -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 ""
- 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(
diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py
index eb46d4a..0e11ca9 100755
--- a/scripts/verify_screen_fits.py
+++ b/scripts/verify_screen_fits.py
@@ -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()