steam-backlog-enforcer/steam_backlog_enforcer/tests/test_budget_view.py
Krzysztof kuhy Rudnicki 63321f16cc
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Pay an hour of gaming budget for a LeetCode solve
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
2026-08-30 15:22:18 +02:00

241 lines
9.4 KiB
Python

"""Tests for _budget_view module — 100% branch coverage."""
from __future__ import annotations
import json
from unittest.mock import patch
from steam_backlog_enforcer import _budget_view as view
from steam_backlog_enforcer import _playtime_state as state_mod
from steam_backlog_enforcer._budget_log_tail import RunningGame, SessionView
from steam_backlog_enforcer._budget_resolve import BudgetResolution
from steam_backlog_enforcer._budget_view import (
CORRUPT,
DENIED,
MISSING,
READABLE,
build_budget_snapshot,
build_rules,
build_today,
next_warning,
state_access,
)
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
# Built at import time, so the autouse _no_workout_http fixture is not in
# effect yet -- without this patch, collecting this module makes a real HTTP
# call to the screen locker and pins RULES to whatever budget today happens to
# have earned. Stub it explicitly so the constant is the earned budget always.
with patch(
"steam_backlog_enforcer._playtime_state.resolve_budget",
side_effect=lambda config: BudgetResolution(
seconds=float(config.base_gaming_seconds)
+ float(config.workout_bonus_seconds)
+ float(config.leetcode_bonus_seconds),
base_seconds=float(config.base_gaming_seconds),
workout_seconds=float(config.workout_bonus_seconds),
leetcode_seconds=float(config.leetcode_bonus_seconds),
reason="stubbed: fully earned",
),
):
RULES = rules_for(Config(), demo=False)
class TestStateAccess:
"""Tests for telling apart the reasons state fails to load."""
def test_missing_file(self) -> None:
assert state_access(state_mod.PLAYTIME_STATE_FILE) == MISSING
def test_readable_file(self) -> None:
state_mod.PLAYTIME_STATE_FILE.write_text("{}", encoding="utf-8")
assert state_access(state_mod.PLAYTIME_STATE_FILE) == READABLE
def test_unreadable_file(self) -> None:
state_mod.PLAYTIME_STATE_FILE.write_text("{}", encoding="utf-8")
with patch("pathlib.Path.read_bytes", side_effect=PermissionError):
assert state_access(state_mod.PLAYTIME_STATE_FILE) == DENIED
class TestNextWarning:
"""Tests for the forward-looking warning threshold."""
def test_largest_threshold_below_remaining(self) -> None:
state = PlaytimeState(day_key="d", seconds=0.0)
assert next_warning(state, RULES) == 3600
def test_skips_thresholds_already_fired(self) -> None:
state = PlaytimeState(day_key="d", seconds=0.0, warned_seconds=[3600])
assert next_warning(state, RULES) == 1800
def test_none_once_every_threshold_is_passed(self) -> None:
state = PlaytimeState(day_key="d", seconds=RULES.budget_seconds)
assert next_warning(state, RULES) is None
def test_is_not_the_already_crossed_threshold(self) -> None:
# pending_warning() would return 3600 here; the display wants the next
# one the countdown is heading towards.
state = PlaytimeState(day_key="d", seconds=RULES.budget_seconds - 3000)
assert next_warning(state, RULES) == 1800
class TestBuildToday:
"""Tests for the today block."""
def test_reports_usage_and_remaining(self) -> None:
today = build_today(PlaytimeState(day_key="2026-08-28", seconds=100.0), RULES)
assert today["seconds_used"] == 100.0
assert today["seconds_remaining"] == RULES.budget_seconds - 100.0
assert today["blocked"] is False
assert today["fraction_used"] < 0.01
def test_never_reports_negative_remaining(self) -> None:
today = build_today(PlaytimeState(day_key="d", seconds=10**9), RULES)
assert today["seconds_remaining"] == 0.0
assert today["fraction_used"] == 1.0
def test_reports_the_cutoff(self) -> None:
today = build_today(
PlaytimeState(day_key="d", seconds=1.0, blocked_at=2.0), RULES
)
assert today["blocked"] is True
assert today["blocked_at"] == 2.0
def test_a_zero_budget_reads_as_fully_spent(self) -> None:
rules = rules_for(
Config(
base_gaming_seconds=0, workout_bonus_seconds=0, leetcode_bonus_seconds=0
),
demo=False,
)
today = build_today(PlaytimeState(day_key="d", seconds=0.0), rules)
assert today["fraction_used"] == 1.0
class TestBuildRules:
"""Tests for the rules block."""
def test_lists_the_policy(self) -> None:
with patch.object(view, "mounted_targets", return_value=set()):
rules = build_rules(RULES)
assert rules["budget_seconds"] == 8 * 3600
assert rules["warn_at"] == [3600, 1800, 600, 300]
assert rules["masked_launchers"] == []
def test_lists_masked_launchers(self) -> None:
with patch.object(view, "mounted_targets", return_value={"/usr/bin/steam"}):
assert build_rules(RULES)["masked_launchers"] == ["/usr/bin/steam"]
class TestBuildBudgetSnapshot:
"""Tests for the whole payload."""
def _snapshot(self, **over: object) -> dict:
kwargs: dict = {
"stored": PlaytimeState(day_key="2026-08-28", seconds=100.0),
"access": READABLE,
"session": SessionView(available=False),
}
kwargs.update(over)
with (
patch.object(view, "load_state", return_value=kwargs["stored"]),
patch.object(view, "state_access", return_value=kwargs["access"]),
patch.object(view, "last_verdict", return_value=kwargs["session"]),
patch.object(view, "mounted_targets", return_value=set()),
):
return build_budget_snapshot(demo=bool(kwargs.get("demo", False)))
def test_reports_a_readable_day(self) -> None:
snapshot = self._snapshot()
assert snapshot["readable"] is True
assert snapshot["state_status"] == READABLE
assert snapshot["error"] is None
assert snapshot["today"]["gaming_day"] == "2026-08-28"
def test_reports_denied_state(self) -> None:
snapshot = self._snapshot(stored=None, access=DENIED)
assert snapshot["readable"] is False
assert snapshot["state_status"] == DENIED
assert "restart" in snapshot["error"]
assert snapshot["today"] is None
# The rest of the payload still comes through.
assert snapshot["rules"]["budget_seconds"] == 8 * 3600
def test_reports_missing_state(self) -> None:
snapshot = self._snapshot(stored=None, access=MISSING)
assert snapshot["state_status"] == MISSING
assert "No gaming time" in snapshot["error"]
def test_a_file_that_opens_but_will_not_parse_is_corrupt(self) -> None:
snapshot = self._snapshot(stored=None, access=READABLE)
assert snapshot["state_status"] == CORRUPT
assert "tampering" in snapshot["error"]
def test_includes_the_live_session(self) -> None:
session = SessionView(
observed_at="2026-08-28T20:42:19+02:00",
state="engaged",
reason="engaged",
causes=["focus"],
idle_seconds=2.0,
screen_held=False,
games=[RunningGame(pid=7, name="hollow_knight")],
available=True,
)
with patch.object(
view.State, "load", return_value=view.State(current_game_name="HK")
):
block = self._snapshot(session=session)["session"]
assert block["available"] is True
assert block["game_name"] == "HK"
assert block["qualifying_count"] == 1
assert block["processes"] == [{"pid": 7, "name": "hollow_knight"}]
def test_includes_recorded_history(self) -> None:
with patch.object(
view,
"load_history",
return_value=[HistoryDay(day="2026-08-27", seconds=1.5)],
):
snapshot = self._snapshot()
# A day with a total but no breakdown is entirely unattributed: the
# residual is computed here, never stored.
assert snapshot["history"] == [
{
"day": "2026-08-27",
"seconds": 1.5,
"segments": [{"key": "unattributed", "seconds": 1.5}],
}
]
assert snapshot["legend"] == [{"key": "unattributed", "label": "Unattributed"}]
def test_demo_runs_serve_no_history(self) -> None:
"""Production days must not be plotted against the 60-second budget."""
day = HistoryDay(day="2026-08-28", seconds=28800.0)
with patch.object(view, "load_history", return_value=[day]) as load:
snapshot = self._snapshot(demo=True)
assert snapshot["history"] == []
assert snapshot["rules"]["demo"] is True
load.assert_not_called()
def test_leaks_no_secret(self) -> None:
"""The API key must never cross the HTTP boundary."""
with patch.object(Config, "load", return_value=Config(steam_api_key="SEKRET")):
snapshot = self._snapshot()
assert "SEKRET" not in json.dumps(snapshot)
assert set(snapshot["rules"]) == {
"budget_seconds",
"enforcement",
"counts_launchers",
"engagement_gate",
"idle_grace_seconds",
"require_game_focus",
"warn_at",
"demo",
"masked_launchers",
"budget_reason",
"bonuses",
}