screen-locker/screen_locker/tests/conftest.py
Krzysztof kuhy Rudnicki 8bc981da4e Split _runnerup_verification, _ui_widgets and _sick_dialog
_runnerup_verification.py (312) -> _runnerup_tcx.py       pull + parse TCX
                                     _runnerup_backfill.py  fill the log for a
                                                            date or a week
  _ui_widgets.py (287)            -> _ui_form_fields.py     labelled fields,
                                                            focus, disable_paste
  _sick_dialog.py (278)           -> _sick_commitment.py    next-day commitment

_ui_form_fields also takes disable_paste, since the fields are what use it;
_ui_widgets re-exports it for _manual_workout_widgets and _sick_dialog.

Two things had to move with the code rather than being left behind:

- conftest's _TK_MODULES gains _ui_form_fields, or its tkinter calls reach a
  real display instead of the mock.
- pyproject's S314 waiver follows the ET.parse call it covers into
  _runnerup_tcx.py. Same approved suppression, new home -- not a new one.

Test count 977 -> 978: _ui_form_fields is a new lock-surface module, so
test_no_popup_widgets safety-checks it too. 100% branch coverage held; pylint
back at 8.70 after fixing a genuinely undefined WidgetGroup annotation the
split exposed. `screen_lock --status` unchanged. Gate: 41 -> 28 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:37:15 +02:00

330 lines
11 KiB
Python

"""Shared fixtures and helpers for screen_locker tests.
Safety:
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` module
reference inside ``screen_lock`` with a MagicMock, replaces ``GateRoot``
with a callable returning that same mock root, and stubs ``sys.exit`` — no
test can create a real Tk root, go fullscreen, or grab input, even one
that forgets to request the explicit ``mock_tk`` fixture.
"""
from __future__ import annotations
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
import pytest
from screen_locker.tests._gatelock_fixtures import (
FAKE_OUTPUTS,
TWO_OUTPUTS,
_hermetic_gatelock,
dual_output,
)
from screen_locker.tests._locker_factories import (
_make_locker,
create_locker,
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",
)
__all__ = [
"FAKE_OUTPUTS",
"TWO_OUTPUTS",
"_hermetic_gatelock",
"_make_locker",
"create_locker",
"create_locker_early_bird",
"create_locker_relaxed_day",
"dual_output",
]
_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
@pytest.fixture(autouse=True)
def _block_real_tk_and_exit() -> Iterator[None]:
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
Patching the entire module (not just ``tk.Tk``) ensures that
**nothing** in tkinter can touch the real display server.
"""
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,
)
)
stack.enter_context(patch("screen_locker.screen_lock.sys.exit"))
# The startup early-exit checks moved to _startup_checks; sys.exit is
# bound there now, so it needs blocking separately.
stack.enter_context(patch("screen_locker._startup_checks.sys.exit"))
yield
@pytest.fixture(autouse=True)
def mock_subprocess_run() -> Generator[MagicMock]:
"""Block real subprocess calls (e.g. setxkbmap) for every test.
Also exposed as a named fixture so individual tests can assert
on the calls made (e.g. VT switching tests).
``shutil.which`` is mocked to return a stable fake path so tests work
regardless of whether setxkbmap is installed on the host machine.
"""
with (
patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"),
patch(f"{_VT_SUBPROCESS}.run") as mock,
):
yield mock
@pytest.fixture(autouse=True)
def _block_real_network() -> Iterator[None]:
"""Block real subnet probes and wttr.in calls for every test.
Otherwise phone verification / StatusWindow's temperature fetch would
reach the real network. Tests needing a real probe patch it locally.
"""
targets = (
# The subnet probes moved out of _phone_verification when it was split;
# both new homes must be blocked or a test reaches the real network.
"screen_locker._adb_transport.socket.create_connection",
"screen_locker._http_workout_fetch.socket.create_connection",
"screen_locker._temperature.urllib.request.urlopen",
)
with ExitStack() as stack:
for target in targets:
stack.enter_context(patch(target, side_effect=OSError("blocked")))
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,
),
patch(
"screen_locker._constants.SICK_HISTORY_FILE",
target,
),
):
yield
@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
@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.
"""
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),
):
yield
@pytest.fixture(autouse=True)
def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
"""Redirect SHUTDOWN_BASE_FILE to tmp_path so tests cannot touch real state.
Pre-seeded with today's date so reset_to_base_if_new_day() is a no-op by
default (matching the real file's steady state) -- tests that want to
exercise the actual reset path patch reset_to_base_if_new_day directly,
same as the rest of the suite already does.
"""
target = tmp_path / "shutdown_base.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
target.write_text(
json.dumps(
{"base_mon_wed_hour": 21, "base_thu_sun_hour": 21, "last_reset_date": today}
)
)
with (
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target),
):
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.
Without this, tests that run on a Tue/Wed/Thu would hit the relaxed-day
branch instead of the full-lock path that existing tests expect.
Setting has_weekly_minimum=False ensures the full lock is shown
(weekly quota not reached → enforce).
"""
with (
patch(
"screen_locker._startup_checks.is_relaxed_day",
return_value=False,
),
patch(
"screen_locker._startup_checks.has_weekly_minimum",
return_value=False,
),
):
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.
``remote_client`` reads ``crdt_sync.CONFIG_FILE`` to decide whether to
build a Firebase-primary mirror. On a developer machine that file *does*
exist, so without this the sync tests would reach the real database and
assert against live data instead of their own fakes. Tests that want the
Firebase path point it back at a file they control.
"""
# CONFIG_FILE is read by remote_client, which lives in _sync_client since
# _workout_sync was split. Patching the old module would leave the real
# config in play and the sync tests would assert against live data.
from screen_locker import _sync_client
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))