mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:33:49 +01:00
The budget was binary: 8h with a counted workout, 6h without. There was no base/bonus structure anywhere -- resolve_budget_seconds returned one of two absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that appeared in no line of code. A second earner could not be bolted onto that shape without coupling the two. It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7 or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to 7h, and a day that earns nothing drops 6h to 5h. The two earners are read independently and added. That is what "must not interfere with the workout" means in code: an unreadable LeetCode ledger leaves a workout day at 7h, not 5h, and there is a test asserting exactly that. LeetCode is read from leetcode-guard's ledger directly -- it answers even when leetcode-guard is not running, which is most of the day -- and falls back to its loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces gatelock's canonicalisation in eight lines because gatelock lives in the user's site-packages and this daemon runs as root, and a pinned vector fails loudly if that ever drifts. Failing to get an answer grants nothing AND raises an incident: desktop notification, warning log, and a line in leetcode_bonus_incidents.log (gitignored, so it never becomes an uncommitted change), rate-limited to once per reason per day. A silently missing hour looks exactly like an unearned one. resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate the second resolution seam this module's docstring warns about. rules_for calls it once and carries the breakdown on PlaytimeRules, so /api/budget reports why today is what it is without resolving again. Known limitation, accepted: leetcode-guard only writes credits from its lock window's poll loop, so a voluntary extra solve after the day is settled never reaches the ledger. Verified live against real services -- all four cases (5/6/7/8h), the endpoint fallback, the fail-closed path with its notification, and the rate limit. 1782 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
227 lines
8.3 KiB
Python
227 lines
8.3 KiB
Python
"""Tests for the daily-gaming-budget CLI handlers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
from steam_backlog_enforcer._cmd_playtime import (
|
|
cmd_enforce,
|
|
cmd_gaming_reset,
|
|
cmd_gaming_status,
|
|
cmd_gaming_unblock,
|
|
)
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
PlaytimeState,
|
|
load_state,
|
|
save_state,
|
|
)
|
|
from steam_backlog_enforcer.config import Config, State
|
|
|
|
PKG = "steam_backlog_enforcer._cmd_playtime"
|
|
|
|
|
|
def _echoed(mock_echo: object) -> str:
|
|
return " ".join(str(c) for c in mock_echo.call_args_list)
|
|
|
|
|
|
class TestCmdEnforce:
|
|
def test_runs_in_production_by_default(self) -> None:
|
|
with patch(f"{PKG}.do_enforce") as mock_run, patch(f"{PKG}._echo"):
|
|
assert cmd_enforce(Config(), State(), []) == 0
|
|
assert mock_run.call_args.kwargs["demo"] is False
|
|
|
|
def test_demo_flag_enables_demo(self) -> None:
|
|
with patch(f"{PKG}.do_enforce") as mock_run, patch(f"{PKG}._echo"):
|
|
assert cmd_enforce(Config(), State(), ["--demo"]) == 0
|
|
assert mock_run.call_args.kwargs["demo"] is True
|
|
|
|
def test_demo_announces_itself(self) -> None:
|
|
with patch(f"{PKG}.do_enforce"), patch(f"{PKG}._echo") as mock_echo:
|
|
cmd_enforce(Config(), State(), ["--demo"])
|
|
assert "DEMO MODE" in _echoed(mock_echo)
|
|
|
|
def test_unknown_flag_exits_nonzero_without_running(self) -> None:
|
|
"""A mistyped --Demo must not silently run the real 8-hour budget."""
|
|
with patch(f"{PKG}.do_enforce") as mock_run, patch(f"{PKG}._echo") as mock_echo:
|
|
assert cmd_enforce(Config(), State(), ["--Demo"]) == 2
|
|
mock_run.assert_not_called()
|
|
assert "Unknown argument" in _echoed(mock_echo)
|
|
|
|
def test_unknown_flag_alongside_demo_still_rejected(self) -> None:
|
|
with patch(f"{PKG}.do_enforce") as mock_run, patch(f"{PKG}._echo"):
|
|
assert cmd_enforce(Config(), State(), ["--demo", "--bogus"]) == 2
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
class TestCmdGamingStatus:
|
|
def test_reports_no_state_yet(self) -> None:
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.mounted_targets", return_value=set()),
|
|
):
|
|
cmd_gaming_status(Config(), State())
|
|
assert "no state recorded yet" in _echoed(mock_echo)
|
|
|
|
def test_reports_usage_and_remaining(self) -> None:
|
|
save_state(PlaytimeState(day_key="2026-07-27", seconds=100.0), demo=False)
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.mounted_targets", return_value=set()),
|
|
):
|
|
cmd_gaming_status(
|
|
Config(
|
|
base_gaming_seconds=500,
|
|
workout_bonus_seconds=0,
|
|
leetcode_bonus_seconds=0,
|
|
),
|
|
State(),
|
|
)
|
|
output = _echoed(mock_echo)
|
|
assert "used:" in output
|
|
assert "100s" in output
|
|
assert "400s" in output
|
|
|
|
def test_hides_the_demo_section_when_unused(self) -> None:
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.mounted_targets", return_value=set()),
|
|
):
|
|
cmd_gaming_status(Config(), State())
|
|
assert "Demo budget" not in _echoed(mock_echo)
|
|
|
|
def test_shows_the_demo_section_when_present(self) -> None:
|
|
save_state(PlaytimeState(day_key="2026-07-27", seconds=5.0), demo=True)
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.mounted_targets", return_value=set()),
|
|
):
|
|
cmd_gaming_status(Config(), State())
|
|
assert "Demo budget" in _echoed(mock_echo)
|
|
|
|
def test_lists_masked_launchers(self) -> None:
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.mounted_targets", return_value={"/usr/bin/steam"}),
|
|
):
|
|
cmd_gaming_status(Config(), State())
|
|
output = _echoed(mock_echo)
|
|
assert "1/4" in output
|
|
assert "/usr/bin/steam" in output
|
|
|
|
def test_reports_blocked_flag(self) -> None:
|
|
save_state(
|
|
PlaytimeState(day_key="2026-07-27", seconds=1.0, blocked_at=5.0),
|
|
demo=False,
|
|
)
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.mounted_targets", return_value=set()),
|
|
):
|
|
cmd_gaming_status(Config(), State())
|
|
assert "blocked: True" in _echoed(mock_echo)
|
|
|
|
|
|
class TestCmdGamingReset:
|
|
def test_requires_root(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=1000),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.release_block") as mock_release,
|
|
):
|
|
assert cmd_gaming_reset(Config(), State()) == 1
|
|
mock_release.assert_not_called()
|
|
assert "requires root" in _echoed(mock_echo)
|
|
|
|
def test_aborts_without_the_typed_confirmation(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=0),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch("builtins.input", return_value="yes"),
|
|
patch(f"{PKG}.release_block") as mock_release,
|
|
):
|
|
assert cmd_gaming_reset(Config(), State()) == 1
|
|
mock_release.assert_not_called()
|
|
assert "Aborted" in _echoed(mock_echo)
|
|
|
|
def test_resets_counter_and_releases_mounts(self) -> None:
|
|
save_state(
|
|
PlaytimeState(day_key="2026-07-26", seconds=999.0, blocked_at=5.0),
|
|
demo=False,
|
|
)
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=0),
|
|
patch(f"{PKG}._echo"),
|
|
patch("builtins.input", return_value="YES"),
|
|
patch(f"{PKG}.release_block", return_value=["/usr/bin/steam"]),
|
|
):
|
|
assert cmd_gaming_reset(Config(), State()) == 0
|
|
stored = load_state(demo=False)
|
|
assert stored.seconds == 0.0
|
|
assert stored.is_blocked() is False
|
|
|
|
def test_reports_how_many_mounts_were_released(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=0),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch("builtins.input", return_value="YES"),
|
|
patch(f"{PKG}.release_block", return_value=["/a", "/b"]),
|
|
):
|
|
cmd_gaming_reset(Config(), State())
|
|
assert "Released 2 mount(s)" in _echoed(mock_echo)
|
|
|
|
|
|
class TestCmdGamingUnblock:
|
|
def test_rejects_arguments(self) -> None:
|
|
with (
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.release_block") as mock_release,
|
|
):
|
|
assert cmd_gaming_unblock(["--force"]) == 2
|
|
mock_release.assert_not_called()
|
|
assert "Unknown argument" in _echoed(mock_echo)
|
|
|
|
def test_requires_root(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=1000),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.release_block") as mock_release,
|
|
):
|
|
assert cmd_gaming_unblock([]) == 1
|
|
mock_release.assert_not_called()
|
|
assert "requires root" in _echoed(mock_echo)
|
|
|
|
def test_reports_when_nothing_was_mounted(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=0),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(f"{PKG}.release_block", return_value=[]),
|
|
):
|
|
assert cmd_gaming_unblock([]) == 0
|
|
assert "No playtime mounts" in _echoed(mock_echo)
|
|
|
|
def test_lists_released_mounts(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=0),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
patch(
|
|
f"{PKG}.release_block",
|
|
return_value=["/usr/bin/steam", "/usr/bin/lutris"],
|
|
),
|
|
):
|
|
assert cmd_gaming_unblock([]) == 0
|
|
output = _echoed(mock_echo)
|
|
assert "/usr/bin/steam" in output
|
|
assert "/usr/bin/lutris" in output
|
|
|
|
def test_consults_no_state_at_all(self) -> None:
|
|
"""The recovery hatch must work with an unreadable state file."""
|
|
with (
|
|
patch(f"{PKG}.os.geteuid", return_value=0),
|
|
patch(f"{PKG}._echo"),
|
|
patch(
|
|
f"{PKG}.load_state", side_effect=AssertionError("must not be called")
|
|
),
|
|
patch(f"{PKG}.release_block", return_value=[]),
|
|
):
|
|
assert cmd_gaming_unblock([]) == 0
|