mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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:
parent
8bc981da4e
commit
76a4c6a0a7
4
.github/workflows/python-tests.yml
vendored
4
.github/workflows/python-tests.yml
vendored
@ -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
|
||||
|
||||
@ -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)$
|
||||
|
||||
64
screen_locker/tests/_opt_in_fixtures.py
Normal file
64
screen_locker/tests/_opt_in_fixtures.py
Normal 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"
|
||||
58
screen_locker/tests/_tk_mocks.py
Normal file
58
screen_locker/tests/_tk_mocks.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
117
screen_locker/tests/test_manual_workout_features_part4.py
Normal file
117
screen_locker/tests/test_manual_workout_features_part4.py
Normal 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()
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
137
screen_locker/tests/test_runnerup_verification_part15.py
Normal file
137
screen_locker/tests/test_runnerup_verification_part15.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -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)
|
||||
|
||||
133
screen_locker/tests/test_screen_lock_coverage_part5.py
Normal file
133
screen_locker/tests/test_screen_lock_coverage_part5.py
Normal 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
15
scripts/__init__.py
Normal 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.
|
||||
"""
|
||||
194
scripts/_popup_form_check.py
Normal file
194
scripts/_popup_form_check.py
Normal 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
72
scripts/_xvfb_reexec.py
Normal 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
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user