tests: collecting test_budget_view never reads the host's leisure switch

Its module-level rules_for runs before any autouse fixture, so a live
/etc/leisure-watch/leisure_from priced RULES. collection_isolated()
points every switch reader at a path that cannot exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 17:06:22 +02:00
parent e0cf931d0e
commit 0ffc773f52
3 changed files with 62 additions and 7 deletions

View File

@ -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

View File

@ -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",

View File

@ -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