steam-backlog-enforcer/steam_backlog_enforcer/tests/test_budget_resolve.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

173 lines
6.1 KiB
Python

"""Tests for _budget_resolve: the floor plus what today earned.
The matrix is the point of this file. Two independent earners give four days,
and the two properties that must hold across all of them are that the sum is
additive (never a choice between two absolutes) and that *not knowing* an
answer is worth exactly as much as a "no" -- never more.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _leetcode_bonus, _workout_budget
from steam_backlog_enforcer._budget_resolve import resolve_budget
from steam_backlog_enforcer.config import Config
if TYPE_CHECKING:
from collections.abc import Iterator
_WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
_LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
_LEETCODE_HTTP = "steam_backlog_enforcer._leetcode_bonus._fetch_leetcode_today"
_HOUR = 3600.0
@pytest.fixture(autouse=True)
def _clear_caches() -> Iterator[None]:
"""Keep both module memos from leaking answers between tests.
Yields:
None, with both caches empty before and after.
"""
_workout_budget.reset_cache()
_leetcode_bonus.reset_cache()
yield
_workout_budget.reset_cache()
_leetcode_bonus.reset_cache()
class TestTheFourDays:
"""Every combination of the two earners, in hours."""
@pytest.mark.parametrize(
("earners", "expected_hours"),
[
((False, False), 5.0),
((False, True), 6.0),
((True, False), 7.0),
((True, True), 8.0),
],
)
def test_the_budget_is_the_sum(
self,
earners: tuple[bool, bool],
expected_hours: float,
) -> None:
"""5h floor, +2h for a workout, +1h for a solve, in every combination.
Args:
earners: Whether the workout and the LeetCode solve happened.
expected_hours: The budget those two should produce.
"""
workout, leetcode = earners
with (
patch(_WORKOUT, return_value=workout),
patch(_LEETCODE, return_value=leetcode),
):
resolved = resolve_budget(Config())
assert resolved.seconds == expected_hours * _HOUR
def test_the_breakdown_matches_the_total(self) -> None:
"""The parts a caller displays must add up to the number enforced."""
with patch(_WORKOUT, return_value=True), patch(_LEETCODE, return_value=True):
resolved = resolve_budget(Config())
assert (
resolved.base_seconds + resolved.workout_seconds + resolved.leetcode_seconds
== resolved.seconds
)
class TestFailingClosed:
"""An answer that could not be obtained is worth nothing, and says so."""
def test_an_unreachable_locker_costs_only_the_workout_bonus(self) -> None:
"""A dead screen locker must not also cost the LeetCode hour."""
with (
patch(_WORKOUT, side_effect=OSError("refused")),
patch(_LEETCODE, return_value=True),
):
resolved = resolve_budget(Config())
assert resolved.seconds == 6.0 * _HOUR
assert resolved.leetcode_seconds == _HOUR
def test_an_unreadable_ledger_costs_only_the_leetcode_bonus(self) -> None:
"""The whole point of the split: LeetCode failing leaves 7h, not 5h."""
with (
patch(_WORKOUT, return_value=True),
patch(_LEETCODE, return_value=None),
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
):
resolved = resolve_budget(Config())
assert resolved.seconds == 7.0 * _HOUR
assert resolved.workout_seconds == 2 * _HOUR
def test_both_unavailable_leaves_the_bare_floor(self) -> None:
"""Neither earner answering can never grant more than the floor."""
with (
patch(_WORKOUT, side_effect=OSError("refused")),
patch(_LEETCODE, return_value=None),
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
):
resolved = resolve_budget(Config())
assert resolved.seconds == 5.0 * _HOUR
def test_unknown_is_reported_differently_from_no(self) -> None:
""" "Could not check" and "not earned" must not read the same."""
with (
patch(_WORKOUT, return_value=False),
patch(_LEETCODE, return_value=None),
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
):
unknown = resolve_budget(Config()).reason
_leetcode_bonus.reset_cache()
_workout_budget.reset_cache()
with patch(_WORKOUT, return_value=False), patch(_LEETCODE, return_value=False):
answered = resolve_budget(Config()).reason
assert unknown != answered
assert "unknown" in unknown
assert "unknown" not in answered
class TestMisconfiguration:
"""Config that would invert the incentive is refused, not enforced."""
def test_a_negative_bonus_is_clamped(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Earning something must never *cost* gaming time.
Args:
caplog: pytest's log capture.
"""
config = Config(workout_bonus_seconds=-3600)
with (
caplog.at_level(logging.ERROR),
patch(_WORKOUT, return_value=True),
patch(_LEETCODE, return_value=False),
):
resolved = resolve_budget(config)
assert resolved.seconds == 5.0 * _HOUR
assert "workout_bonus_seconds is negative" in caplog.text
def test_the_budget_is_explained(self, caplog: pytest.LogCaptureFixture) -> None:
"""A three-hour swing between days should never be silent.
Args:
caplog: pytest's log capture.
"""
with (
caplog.at_level(logging.INFO),
patch(_WORKOUT, return_value=False),
patch(_LEETCODE, return_value=False),
):
resolve_budget(Config())
assert "5.0h" in caplog.text
assert "no counted workout" in caplog.text
assert "no LeetCode solve recorded" in caplog.text