screen-locker/screen_locker/tests/conftest.py
Krzysztof kuhy Rudnicki ec0946f64e Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead
of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather
than PEP 695 syntax, and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That last one makes the source
3.14-only at the syntax level, which is what `requires-python = ">=3.14"`
already promised; kuhy chose it deliberately over holding ruff a release back
(2026-08-29).

The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])`
to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the
module-level `_W`, a *different* type variable -- mypy caught it as "a function
returning TypeVar should receive at least one argument containing the same
TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__`
the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead
module-level TypeVars.

ruff, ruff-format, mypy --strict and both pylint passes clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:42:47 +02:00

237 lines
8.4 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 UTC, datetime
import json
from pathlib import Path
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._isolated_state import ISOLATED_STATE
from screen_locker.tests._locker_factories import (
_make_locker,
create_locker,
create_locker_early_bird,
create_locker_relaxed_day,
)
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",
]
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
@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_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Point ``$HOME`` and ``Path.home()`` at tmp_path for every test.
``ISOLATED_STATE`` cannot cover this. Everything under ``~/.config`` is
resolved at *call* time by ``crdt_sync.credential_store_for``
(``Path.home() / ".config" / app / "firebase_auth.json"``), not bound to a
module-level constant, so there is no attribute to patch -- the only seam
is home itself.
What this protects is a live credential, not a scratch file. A test that
reaches ``credential_store_for("screen_locker")`` and saves would overwrite
the machine's real Firebase session with fixture values, and the damage is
invisible until the next sync fails with HTTP 401. The same redirect also
covers the sync PAT and the shared ``~/.config/crdt-sync/`` password and
OAuth secret, which are read the same way.
``Path.home()`` is patched as well as ``$HOME``: it consults ``os.environ``
on POSIX, but only via ``expanduser``, and a test that patches the
environment differently must not silently regain access to the real one.
"""
monkeypatch.setenv("HOME", str(tmp_path))
with patch.object(Path, "home", lambda: tmp_path):
yield
@pytest.fixture(autouse=True)
def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
"""Redirect every on-disk state path to tmp_path for every test.
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. The
table itself (ISOLATED_STATE) has no such constraint -- see
_isolated_state.py.
"""
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
@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=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 _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(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"))