mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 16: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
173 lines
6.1 KiB
Python
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
|