diff --git a/steam_backlog_enforcer/tests/_isolate_leisure.py b/steam_backlog_enforcer/tests/_isolate_leisure.py index 7845af3..4860f2f 100644 --- a/steam_backlog_enforcer/tests/_isolate_leisure.py +++ b/steam_backlog_enforcer/tests/_isolate_leisure.py @@ -10,10 +10,16 @@ Phase 2 on by writing a date to :func:`switch_file`. The leisure kill scan reads ``/proc`` through its own constant; it gets an empty directory (:func:`leisure_proc`), so no test can see -- or signal -- a live browser or player. + +Module-level code runs at collection, before any fixture: it wraps itself in +:func:`collection_isolated` instead. """ from __future__ import annotations +from contextlib import ExitStack, contextmanager +import os +from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import patch @@ -21,10 +27,25 @@ import pytest if TYPE_CHECKING: from collections.abc import Iterator - from pathlib import Path _SWITCH_NAME = "leisure_from" _PROC_NAME = "leisure_proc" +# Every switch reader, by the module attribute it looks up at call time. +_SWITCH_READERS = ( + "earned_time._leisure.LEISURE_FROM_PATH", + "steam_backlog_enforcer._leisure_mode.LEISURE_FROM_PATH", +) +# Can never exist (/dev/null is not a directory): always the parked date. +_NO_SWITCH = Path(os.devnull) / _SWITCH_NAME + + +@contextmanager +def collection_isolated() -> Iterator[None]: + """Point every switch reader at a file that cannot exist (no ``tmp_path``).""" + with ExitStack() as stack: + for target in _SWITCH_READERS: + stack.enter_context(patch(target, _NO_SWITCH)) + yield def switch_file(tmp_path: Path) -> Path: @@ -44,9 +65,8 @@ def _isolate_leisure(tmp_path: Path) -> Iterator[None]: switch.parent.mkdir(exist_ok=True) proc = leisure_proc(tmp_path) proc.mkdir(exist_ok=True) - with ( - patch("earned_time._leisure.LEISURE_FROM_PATH", switch), - patch("steam_backlog_enforcer._leisure_mode.LEISURE_FROM_PATH", switch), - patch("steam_backlog_enforcer._leisure_procs._PROC", proc), - ): + with ExitStack() as stack: + for target in _SWITCH_READERS: + stack.enter_context(patch(target, switch)) + stack.enter_context(patch("steam_backlog_enforcer._leisure_procs._PROC", proc)) yield diff --git a/steam_backlog_enforcer/tests/test_budget_view.py b/steam_backlog_enforcer/tests/test_budget_view.py index 75487ae..ada75fe 100644 --- a/steam_backlog_enforcer/tests/test_budget_view.py +++ b/steam_backlog_enforcer/tests/test_budget_view.py @@ -22,6 +22,7 @@ from steam_backlog_enforcer._budget_view import ( from steam_backlog_enforcer._playtime_history import HistoryDay from steam_backlog_enforcer._playtime_state import PlaytimeState, rules_for from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.tests._isolate_leisure import collection_isolated from steam_backlog_enforcer.tests._no_workout_http import earned_budget, fixed_budget # Built at import time, so the autouse _no_workout_http fixture is not in @@ -30,8 +31,10 @@ from steam_backlog_enforcer.tests._no_workout_http import earned_budget, fixed_b # have earned. Stub it explicitly so the constant is the earned budget always. # The held budget is pinned for the same reason: conftest's redirects are not # active at import either, so rules_for would read the LIVE playtime_state.json -# and hold RULES at whatever high-water mark it records today. +# and hold RULES at whatever high-water mark it records today. Nor would the +# leisure switch be: the host's /etc/leisure-watch/leisure_from would price it. with ( + collection_isolated(), patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0), patch( "steam_backlog_enforcer._playtime_state.resolve_budget", diff --git a/steam_backlog_enforcer/tests/test_budget_view_collection.py b/steam_backlog_enforcer/tests/test_budget_view_collection.py new file mode 100644 index 0000000..f8ca1b5 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_budget_view_collection.py @@ -0,0 +1,32 @@ +"""Collecting test_budget_view never reads the host's leisure switch file.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer._playtime_state import rules_for +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.tests import test_budget_view +from steam_backlog_enforcer.tests._no_workout_http import earned_budget + +if TYPE_CHECKING: + from pathlib import Path + + +def test_a_live_host_switch_does_not_leak_into_rules(tmp_path: Path) -> None: + with ( + patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0), + patch( + "steam_backlog_enforcer._playtime_state.resolve_budget", + side_effect=earned_budget, + ), + ): + dormant = rules_for(Config(), demo=False) + # The host went live long ago: what the import-time read would see. + host = tmp_path / "host_leisure_from" + host.write_text("2000-01-01\n", encoding="utf-8") + with patch("earned_time._leisure.LEISURE_FROM_PATH", host): + reloaded = importlib.reload(test_budget_view) + assert dormant == reloaded.RULES