"""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" # Local day, as the code compares it (_day.today_str), not the UTC one. today = datetime.now(tz=UTC).astimezone().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"))