mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Tests isolate FOCUS_PROJECT_DATA so a live project never leaks in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
250 lines
9.0 KiB
Python
250 lines
9.0 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 import _sync_client
|
|
from screen_locker._degraded_sources import clear_degraded_sources
|
|
from screen_locker._earned import watched_earners
|
|
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._late_credit_fixtures import _untimed_credits_land_at_noon
|
|
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_fixture,
|
|
mock_tk,
|
|
temp_log_file,
|
|
)
|
|
from screen_locker.tests._tk_mocks import (
|
|
_TK_MODULES,
|
|
_VT_SHUTIL,
|
|
_VT_SUBPROCESS,
|
|
_make_mock_tk,
|
|
)
|
|
|
|
# Re-exported: a sibling module's fixture is visible to tests only once
|
|
# conftest imports it, and __all__ keeps ruff from pruning them as unused.
|
|
__all__ = [
|
|
"FAKE_OUTPUTS",
|
|
"TWO_OUTPUTS",
|
|
"_hermetic_gatelock",
|
|
"_make_locker",
|
|
"_mock_sys_exit",
|
|
"_untimed_credits_land_at_noon",
|
|
"create_locker",
|
|
"create_locker_early_bird",
|
|
"create_locker_relaxed_day",
|
|
"dual_output",
|
|
"mock_sys_exit_fixture",
|
|
"mock_tk",
|
|
"temp_log_file",
|
|
]
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator, Iterator
|
|
|
|
|
|
# Real-state isolation; sync fixtures listed too: `-o addopts=""` drops `-p`.
|
|
_SYNC = "screen_locker.tests.test_sync_fixtures"
|
|
pytest_plugins = ("screen_locker.tests._real_state_fixtures", _SYNC)
|
|
|
|
|
|
@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 _isolate_morning_session(tmp_path: Path) -> Iterator[None]:
|
|
"""Keep the morning-session reader off the real signed file, and never
|
|
let it sleep: a test that forgets would read the developer's actual
|
|
morning and, on the enforce path, wait 30 real seconds for it."""
|
|
with (
|
|
patch(
|
|
"screen_locker._morning_session.MORNING_SESSION_FILE",
|
|
tmp_path / "morning_session.json",
|
|
),
|
|
patch("screen_locker._morning_session.MORNING_RETRY_SECONDS", 0.0),
|
|
):
|
|
yield
|
|
|
|
|
|
@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 _clear_degraded_sources() -> None:
|
|
"""Reset the process-scoped degraded-backend registry around every test.
|
|
|
|
``_degraded_sources._degraded`` is a module-level list, so a test that
|
|
records a failed Firebase read leaks into every test after it. Invisible
|
|
in file order; only ``pytest-randomly`` (which the CI mirror enables and
|
|
the local venvs do not) exposes it -- it failed on push and nowhere else.
|
|
"""
|
|
clear_degraded_sources()
|
|
|
|
|
|
@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
|
|
|
|
|
|
# Every registry's flat earners (a live focus project hides some today).
|
|
_BONUS = [f"{e.name}_bonus_date" for e in watched_earners() if e.kind == "flat"]
|
|
|
|
|
|
@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.
|
|
Every flat earner's stamp is seeded too, so apply_flat_bonuses_if_new()
|
|
never reads a ledger (or the real HMAC key), and sync_now's ledger-watch
|
|
refresh sees an empty unit dir, never the real one.
|
|
"""
|
|
target = tmp_path / "shutdown_base.json"
|
|
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
|
target.write_text(json.dumps(dict.fromkeys(["last_reset_date", *_BONUS], today)))
|
|
with (
|
|
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
|
|
patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target),
|
|
patch("screen_locker._sync_mixin.SHUTDOWN_BASE_FILE", target),
|
|
patch("screen_locker._cli.SHUTDOWN_BASE_FILE", target),
|
|
patch("screen_locker._earner_units.USER_UNIT_DIR", tmp_path / "units"),
|
|
):
|
|
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.
|
|
|
|
``sync_client`` (the ONLY way push, pull and the tombstone script get a
|
|
client) reads ``_sync_client.CONFIG_FILE`` to decide whether to build the
|
|
Firebase client. On a developer machine that file *does* exist, so
|
|
without this the sync tests would reach the real database and assert
|
|
against live data. Tests wanting Firebase point it at a file they own.
|
|
"""
|
|
# Patch _sync_client's binding: crdt_sync's own CONFIG_FILE is bound to
|
|
# the real home at import, which _isolate_home cannot redirect.
|
|
|
|
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
|