mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 17:33:48 +01:00
8h is now earned rather than given. A gaming day with a counted workout logged in screen-locker gets daily_gaming_seconds; a day without gets the new unearned_gaming_seconds floor. Skipping a workout costs two hours the same day, instead of nothing until the weekly lock fires. The fact comes from screen-locker's GET /api/status, which reports only workout_today -- the hour values live here. Neither repo can drift from the other about what a workout day is: that flag is derived from count_day_credits, the same rule the locker enforces with. Every way of not getting an answer applies the floor, logged at warning with the URL and the concrete error. Failing open would let the coupling be defeated by stopping one user service, and a silent 8h would be indistinguishable from an earned one. resolve_budget_seconds is called from rules_for rather than at each call site, because the daemon holds one Config for its whole life while _budget_view reloads it per HTTP request. Resolved at only one of those and the web UI would report a budget the daemon was not enforcing -- the exact silent divergence this codebase is built to prevent. A budget that rises mid-day now releases the block properly. _release_after_raise clears blocked_at, which otherwise only roll_over reset and which would leave the UI reporting "blocked" while gaming worked, and clears warned_seconds, which is keyed on seconds *remaining* and would otherwise let the extra two hours arrive with no warnings at all. Known and accepted (2026-08-29): the budget can DROP mid-day if the resolved answer changes under a day already in progress, re-pricing time already spent and cutting off on the next tick with no warning. The module docstring says so plainly rather than claiming a monotonicity the code does not implement; the fix, if it ever bites, is a persisted granted_budget_seconds. Tests get an autouse stub for the lookup. Without it every test that builds rules makes a real loopback request, and the suite would pass in the morning and fail in the evening depending on whether the machine's owner had trained. test_budget_view builds its RULES at import time, before fixtures apply, so it stubs the resolver explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
226 lines
9.2 KiB
Python
226 lines
9.2 KiB
Python
"""Safety conftest: prevent tests from touching real Steam/config files.
|
|
|
|
Redirects all filesystem paths used by the steam_backlog_enforcer package
|
|
to temporary directories. This stops tests from accidentally:
|
|
- Deleting real game files via uninstall_other_games / uninstall_game
|
|
- Overwriting ~/.config/steam_backlog_enforcer/state.json (losing the
|
|
user's current assignment)
|
|
- Reading real appmanifest files from ~/.local/share/Steam/steamapps
|
|
- Modifying /etc/hosts via the store blocker
|
|
- Corrupting the HLTB cache on disk
|
|
- Launching real Steam or calling real subprocess commands
|
|
- Deleting real ~/.steam, ~/.local/share/Steam, etc. via the total-block
|
|
Steam/Proton remnant purge
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Imported for its autouse side effect: naming it here registers it.
|
|
from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime
|
|
from steam_backlog_enforcer.tests._no_subprocess import _block_real_subprocesses
|
|
from steam_backlog_enforcer.tests._no_workout_http import _no_workout_http
|
|
|
|
# Re-exported so ruff --fix does not delete the imports above: pytest
|
|
# registers autouse fixtures by name, so they look unused to the linter.
|
|
__all__ = ["_block_real_subprocesses", "_isolate_playtime", "_no_workout_http"]
|
|
|
|
from steam_backlog_enforcer.tests._total_block_paths import (
|
|
BLOCK,
|
|
HOSTS,
|
|
IPTABLES,
|
|
PURGE,
|
|
Paths,
|
|
build_paths,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect all real filesystem paths to a temporary directory.
|
|
|
|
Individual tests that also patch these paths will simply override
|
|
this fixture's patches for the duration of their own ``with`` block.
|
|
"""
|
|
fake_config = tmp_path / "config"
|
|
fake_config.mkdir()
|
|
fake_steamapps = tmp_path / "steamapps"
|
|
fake_steamapps.mkdir()
|
|
fake_hosts = tmp_path / "hosts"
|
|
|
|
with (
|
|
# Config / state / snapshot paths (used by State.save, Config.save, etc.)
|
|
patch(
|
|
"steam_backlog_enforcer.config.CONFIG_DIR",
|
|
fake_config,
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer.config.CONFIG_FILE",
|
|
fake_config / "config.json",
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer.config.STATE_FILE",
|
|
fake_config / "state.json",
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer.config.SNAPSHOT_FILE",
|
|
fake_config / "snapshot.json",
|
|
),
|
|
# Steam game manifests / install dirs. STEAMAPPS_PATH is canonical in
|
|
# _steam_state; game_install imports it, so both bindings need the
|
|
# patch (same gotcha as HOSTS_FILE below).
|
|
patch(
|
|
"steam_backlog_enforcer._steam_state.STEAMAPPS_PATH",
|
|
fake_steamapps,
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
|
fake_steamapps,
|
|
),
|
|
# HLTB cache file (computed at import time from CONFIG_DIR, so
|
|
# patching CONFIG_DIR alone does not redirect it)
|
|
patch(
|
|
"steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
|
|
fake_config / "hltb_cache.json",
|
|
),
|
|
# /etc/hosts (store blocker + total block - each module has its own
|
|
# `from ... import HOSTS_FILE` binding, so each needs its own patch)
|
|
patch(
|
|
"steam_backlog_enforcer.store_blocker.HOSTS_FILE",
|
|
fake_hosts,
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._total_block_hosts.HOSTS_FILE",
|
|
fake_hosts,
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer.config.HOSTS_FILE",
|
|
fake_hosts,
|
|
),
|
|
# Total-block lock + IP cache (computed at import time from
|
|
# CONFIG_DIR, so patching CONFIG_DIR alone does not redirect them -
|
|
# same gotcha as HLTB_CACHE_FILE above. A real total-block lock may
|
|
# be active on the host machine; tests must never touch it.)
|
|
patch(
|
|
"steam_backlog_enforcer._total_block.TOTAL_BLOCK_LOCK_FILE",
|
|
fake_config / "total_block_lock.json",
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._total_block_iptables._IPTABLES_IP_CACHE_FILE",
|
|
fake_config / "total_block_ip_cache.json",
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._total_block_purge._STEAM_PURGE_LOG_FILE",
|
|
fake_config / "total_block_purge_log.json",
|
|
),
|
|
# Steam/Proton remnant paths (real ~/.steam, ~/.local/share/Steam,
|
|
# etc. - _remove_steam_remnants() deletes these, so tests must never
|
|
# see the real ones)
|
|
patch(
|
|
"steam_backlog_enforcer._total_block_purge._STEAM_REMNANT_PATHS",
|
|
(
|
|
tmp_path / "fake_home" / ".steam",
|
|
tmp_path / "fake_home" / "steam",
|
|
tmp_path / "fake_home" / ".local" / "share" / "Steam",
|
|
tmp_path / "fake_home" / ".steampath",
|
|
tmp_path / "fake_home" / ".steampid",
|
|
tmp_path / "fake_home" / ".config" / "steamtinkerlaunch",
|
|
tmp_path / "fake_home" / ".config" / "CSDSteamBuild",
|
|
),
|
|
),
|
|
# Derived from CONFIG_DIR at import time, so it captures the REAL path
|
|
# before this fixture redirects CONFIG_DIR. Without it the suite
|
|
# rewrites the user's owned-games cache.
|
|
patch(
|
|
"steam_backlog_enforcer._owned_apps_cache._OWNED_IDS_CACHE_FILE",
|
|
fake_config / "owned_app_ids_cache.json",
|
|
),
|
|
# Whitelist exception files (module-level constants; these live in
|
|
# _whitelist_locking, which is where the code that reads them is)
|
|
patch(
|
|
"steam_backlog_enforcer._whitelist_locking.APPROVED_EXCEPTIONS_FILE",
|
|
fake_config / "approved_exceptions.json",
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._whitelist_locking.EXCEPTION_AUDIT_LOG",
|
|
fake_config / "exception_audit.log",
|
|
),
|
|
# _enforce_loop imports CONFIG_FILE directly; patch the local binding so
|
|
# lock_enforcement_files() uses the tmp path instead of the real one.
|
|
patch(
|
|
"steam_backlog_enforcer._enforce_loop.CONFIG_FILE",
|
|
fake_config / "config.json",
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_real_sleep() -> Iterator[None]:
|
|
"""No-op every ``time.sleep`` used by the package.
|
|
|
|
Several modules call ``time.sleep`` for Steam-launch / install-retry /
|
|
rate-limit pacing. Individual tests that need to observe sleep
|
|
behaviour can override these patches inside their own ``with`` block.
|
|
"""
|
|
noop = MagicMock()
|
|
with (
|
|
patch("steam_backlog_enforcer._steam_client.time.sleep", noop),
|
|
patch("steam_backlog_enforcer._steam_launch.time.sleep", noop),
|
|
patch("steam_backlog_enforcer._steam_api_client.time.sleep", noop),
|
|
patch("steam_backlog_enforcer._enforce_loop.time.sleep", noop),
|
|
):
|
|
yield
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Total-block path redirection
|
|
#
|
|
# Lives here rather than in a helper module because pytest resolves
|
|
# fixtures by name: an imported fixture looks unused to ruff, and this
|
|
# repo runs `ruff --fix --unsafe-fixes`, which deletes the import.
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def total_block_paths(tmp_path: Path) -> Iterator[Paths]:
|
|
"""Redirect every total-block path constant to tmp_path for one test."""
|
|
built = build_paths(tmp_path)
|
|
with (
|
|
patch(f"{BLOCK}.TOTAL_BLOCK_LOCK_FILE", built.lock_file),
|
|
patch(f"{IPTABLES}._IPTABLES_IP_CACHE_FILE", built.ip_cache_file),
|
|
patch(f"{HOSTS}.HOSTS_FILE", built.hosts_file),
|
|
patch(f"{PURGE}._STEAM_PURGE_LOG_FILE", built.purge_log_file),
|
|
patch(f"{PURGE}._STEAM_REMNANT_PATHS", built.remnant_paths),
|
|
):
|
|
yield built
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Live process-table isolation
|
|
#
|
|
# The Steam-restart guard asks whether a game is running by scanning
|
|
# /proc for SteamAppId. Unpatched, that reads the *developer's* real
|
|
# process table: running the suite while playing a game made three
|
|
# restart tests fail on 2026-08-28. Tests that care about the guard
|
|
# patch this themselves.
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_live_games() -> Iterator[None]:
|
|
"""Report no running games unless a test says otherwise."""
|
|
with patch(
|
|
"steam_backlog_enforcer._steam_restart_guard.get_running_steam_game_pids",
|
|
return_value={},
|
|
):
|
|
yield
|