mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 15:53:53 +01:00
main.py became main/ with __main__.py, so the invocation string baked into
run.sh and the systemd unit (`python -m steam_backlog_enforcer.main`) stays
byte-identical and nothing needs redeploying. Commands are grouped into
sibling modules (status, install, picks, abandon, misc), with _shared.py as
a leaf holding the lock helpers and constants both need, and _registry.py
holding the command tables. main/__init__.py keeps every public and private
name importable, so `from steam_backlog_enforcer.main import X` is unchanged.
The subtle part is patch targets. The test suite builds them as
f"{PKG}.{name}", so a grep for the literal module path found nothing -- but
once a command moves to a submodule it resolves its dependencies from that
module's globals, and patching the main package's re-export silently stops
biting. Measured which of the 36 patched symbols moved: 8 could have passed
vacuously. Rather than repair PKG per class, the five test_main*.py files
(all cap violators anyway) were regrouped to mirror the production modules,
so each file has exactly one correct PKG. Verified by mutation: breaking a
line in status/misc/picks/_shared each makes the corresponding tests fail.
test_web_server.py patched main_mod.serve, which no longer intercepts
cmd_serve; repointed at main.misc and confirmed it fails when the call is
removed. Added tests for the __main__ shim, which coverage would otherwise
report at 0% under fail_under = 100.
Shared test builders live in tests/_main_helpers.py, reachable because the
Phase 0 name-tests-test exclude allows tests/_*.py.
1290 tests pass; every main/ module reports 100% coverage; `main`, `status`,
`bogus` and `--status` produce byte-identical output and exit codes to
pre-refactor. 55 -> 49 files over the cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
215 lines
9.2 KiB
Python
215 lines
9.2 KiB
Python
"""Tests for the main CLI: shared lock helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from steam_backlog_enforcer._total_block import TotalBlockStatus
|
|
from steam_backlog_enforcer.config import State
|
|
from steam_backlog_enforcer.main import (
|
|
_TOTAL_BLOCK_EXEMPT_COMMANDS,
|
|
_enforce_manual_pick_lock,
|
|
_enforce_total_block_lock,
|
|
_is_manual_pick_locked,
|
|
_show_manual_pick_lock_message,
|
|
_show_total_block_lock_message,
|
|
)
|
|
from steam_backlog_enforcer.tests._main_helpers import (
|
|
ACTIVE_STATUS,
|
|
EXPIRED_AT,
|
|
STARTED_AT,
|
|
locked_state,
|
|
)
|
|
|
|
PKG = "steam_backlog_enforcer.main._shared"
|
|
|
|
|
|
class TestIsManualPickLocked:
|
|
def test_no_manual_pick_not_locked(self) -> None:
|
|
assert _is_manual_pick_locked(State()) is False
|
|
|
|
def test_game_finished_not_locked(self) -> None:
|
|
state = locked_state(app_id=100)
|
|
state.finished_app_ids = [100]
|
|
assert _is_manual_pick_locked(state) is False
|
|
|
|
def test_deadline_passed_not_locked(self) -> None:
|
|
state = locked_state(started_at=EXPIRED_AT)
|
|
assert _is_manual_pick_locked(state) is False
|
|
|
|
def test_active_lock_returns_true(self) -> None:
|
|
state = locked_state(started_at=STARTED_AT)
|
|
assert _is_manual_pick_locked(state) is True
|
|
|
|
def test_no_started_at_stays_locked(self) -> None:
|
|
# Missing timestamp → cannot determine deadline → stays locked.
|
|
state = locked_state(started_at="")
|
|
assert _is_manual_pick_locked(state) is True
|
|
|
|
def test_invalid_started_at_stays_locked(self) -> None:
|
|
state = locked_state(started_at="not-a-date")
|
|
assert _is_manual_pick_locked(state) is True
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# _show_manual_pick_lock_message
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestShowManualPickLockMessage:
|
|
def test_shows_game_info(self) -> None:
|
|
state = locked_state(app_id=42, name="MyGame", started_at=STARTED_AT)
|
|
with patch(f"{PKG}._echo") as mock_echo:
|
|
_show_manual_pick_lock_message(state)
|
|
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
|
assert "MyGame" in output
|
|
assert "42" in output
|
|
|
|
def test_shows_deadline_when_started_at_valid(self) -> None:
|
|
state = locked_state(started_at=STARTED_AT)
|
|
with patch(f"{PKG}._echo") as mock_echo:
|
|
_show_manual_pick_lock_message(state)
|
|
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
|
assert "Deadline" in output
|
|
|
|
def test_no_crash_on_invalid_started_at(self) -> None:
|
|
state = locked_state(started_at="bad-date")
|
|
with patch(f"{PKG}._echo"):
|
|
_show_manual_pick_lock_message(state) # must not raise
|
|
|
|
def test_no_crash_on_empty_started_at(self) -> None:
|
|
state = locked_state(started_at="")
|
|
with patch(f"{PKG}._echo"):
|
|
_show_manual_pick_lock_message(state) # must not raise
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# _enforce_manual_pick_lock
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestEnforceManualPickLock:
|
|
def test_no_lock_passes(self) -> None:
|
|
_enforce_manual_pick_lock("scan", State()) # no exit
|
|
|
|
def test_exempt_command_passes_while_locked(self) -> None:
|
|
state = locked_state()
|
|
_enforce_manual_pick_lock("done", state) # no exit
|
|
_enforce_manual_pick_lock("status", state) # no exit
|
|
|
|
def test_blocked_command_exits(self) -> None:
|
|
state = locked_state()
|
|
with (
|
|
patch(f"{PKG}._show_manual_pick_lock_message"),
|
|
pytest.raises(SystemExit) as exc_info,
|
|
):
|
|
_enforce_manual_pick_lock("scan", state)
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_add_exception_blocked_when_locked(self) -> None:
|
|
state = locked_state()
|
|
with (
|
|
patch(f"{PKG}._show_manual_pick_lock_message"),
|
|
pytest.raises(SystemExit),
|
|
):
|
|
_enforce_manual_pick_lock("add-exception", state)
|
|
|
|
def test_pick_manual_allowed_when_already_locked(self) -> None:
|
|
# A second pick must be reachable while the first holds the lock; the
|
|
# cap inside cmd_pick_manual is what limits it, not the lock check.
|
|
_enforce_manual_pick_lock("pick-manual", locked_state())
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# _resolve_game_name
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestShowTotalBlockLockMessage:
|
|
def test_shows_remaining_time(self) -> None:
|
|
with patch(f"{PKG}._echo") as mock_echo:
|
|
_show_total_block_lock_message(ACTIVE_STATUS)
|
|
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
|
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
|
assert "Days remaining" in output
|
|
|
|
def test_lists_exempt_commands(self) -> None:
|
|
with patch(f"{PKG}._echo") as mock_echo:
|
|
_show_total_block_lock_message(ACTIVE_STATUS)
|
|
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
|
assert "status" in output
|
|
assert "enforce" in output
|
|
|
|
def test_no_crash_without_until(self) -> None:
|
|
status = TotalBlockStatus(
|
|
active=True, started_at=None, until=None, days=1, days_remaining=0.5
|
|
)
|
|
with patch(f"{PKG}._echo"):
|
|
_show_total_block_lock_message(status) # must not raise
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# _enforce_total_block_lock
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestEnforceTotalBlockLock:
|
|
def test_not_active_passes(self) -> None:
|
|
with patch(f"{PKG}.is_total_block_active", return_value=False):
|
|
_enforce_total_block_lock("scan") # no exit
|
|
|
|
def test_exempt_command_passes_while_active(self) -> None:
|
|
with patch(f"{PKG}.is_total_block_active", return_value=True):
|
|
_enforce_total_block_lock("status") # no exit
|
|
_enforce_total_block_lock("enforce") # no exit
|
|
|
|
def test_blocked_command_exits(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.is_total_block_active", return_value=True),
|
|
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
|
patch(f"{PKG}._show_total_block_lock_message"),
|
|
pytest.raises(SystemExit) as exc_info,
|
|
):
|
|
_enforce_total_block_lock("scan")
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_done_blocked_while_active(self) -> None:
|
|
"""Stricter than the manual-pick lock: even 'done' is blocked."""
|
|
with (
|
|
patch(f"{PKG}.is_total_block_active", return_value=True),
|
|
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
|
patch(f"{PKG}._show_total_block_lock_message"),
|
|
pytest.raises(SystemExit),
|
|
):
|
|
_enforce_total_block_lock("done")
|
|
|
|
def test_add_exception_blocked_while_active(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.is_total_block_active", return_value=True),
|
|
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
|
patch(f"{PKG}._show_total_block_lock_message"),
|
|
pytest.raises(SystemExit),
|
|
):
|
|
_enforce_total_block_lock("add-exception")
|
|
|
|
def test_exempt_set_is_stricter_than_manual_pick(self) -> None:
|
|
"""Pinned exactly: widening this set weakens the total block.
|
|
|
|
gaming-unblock is present because a playtime bind mount makes the
|
|
total block's own `pacman -R steam` fail EBUSY - it has to stay
|
|
reachable exactly when the two collide. gaming-reset is deliberately
|
|
absent: it would shorten enforcement.
|
|
"""
|
|
assert (
|
|
frozenset({"status", "enforce", "gaming-status", "gaming-unblock"})
|
|
== _TOTAL_BLOCK_EXEMPT_COMMANDS
|
|
)
|
|
assert "gaming-reset" not in _TOTAL_BLOCK_EXEMPT_COMMANDS
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# cmd_block_gaming
|
|
# ──────────────────────────────────────────────────────────────
|