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

142 lines
5.4 KiB
Python

"""Tests for _leetcode_bonus: the two transports and the memo.
The chain is the point: the ledger answers first because it works when
leetcode-guard is not running, the endpoint covers an unreadable ledger, and
only the failure of *both* is ``None`` -- which the caller must turn into an
incident rather than a silent lost hour.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _leetcode_bonus
from steam_backlog_enforcer._leetcode_bonus import leetcode_solved_today, reset_cache
from steam_backlog_enforcer.config import Config
if TYPE_CHECKING:
from collections.abc import Iterator
_PKG = "steam_backlog_enforcer._leetcode_bonus"
@pytest.fixture(autouse=True)
def _clear_cache() -> Iterator[None]:
"""Keep the module memo from leaking answers between tests.
Yields:
None, with the cache empty before and after.
"""
reset_cache()
yield
reset_cache()
class TestTheFallbackChain:
"""Ledger first, endpoint second, None only when neither answers."""
def test_the_ledger_wins_when_it_answers(self) -> None:
"""The endpoint must not be consulted when the file was readable."""
with (
patch(f"{_PKG}.read_ledger_solved_today", return_value=True),
patch(f"{_PKG}._fetch_leetcode_today") as fetch,
):
assert leetcode_solved_today(Config()) is True
assert fetch.call_count == 0
def test_the_endpoint_covers_an_unreadable_ledger(self) -> None:
"""This is the whole reason the second transport exists."""
with (
patch(f"{_PKG}.read_ledger_solved_today", return_value=None),
patch(f"{_PKG}._fetch_leetcode_today", return_value=True),
):
assert leetcode_solved_today(Config()) is True
def test_both_failing_is_none(self) -> None:
"""Never False: the caller must be able to raise an incident."""
with (
patch(f"{_PKG}.read_ledger_solved_today", return_value=None),
patch(f"{_PKG}._fetch_leetcode_today", side_effect=OSError("refused")),
patch(f"{_PKG}.report_leetcode_incident") as incident,
):
assert leetcode_solved_today(Config()) is None
assert incident.call_count == 1
def test_an_unusable_endpoint_answer_is_none(self) -> None:
"""A malformed payload is not a "no"."""
with (
patch(f"{_PKG}.read_ledger_solved_today", return_value=None),
patch(f"{_PKG}._fetch_leetcode_today", side_effect=KeyError("leetcode")),
patch(f"{_PKG}.report_leetcode_incident") as incident,
):
assert leetcode_solved_today(Config()) is None
assert incident.call_count == 1
def test_a_successful_answer_is_cached(self) -> None:
"""The daemon ticks often; the ledger must not be re-read each time."""
with patch(f"{_PKG}.read_ledger_solved_today", return_value=True) as read:
leetcode_solved_today(Config())
leetcode_solved_today(Config())
assert read.call_count == 1
def test_a_failure_is_not_cached(self) -> None:
"""A restarted service must take effect on the very next tick."""
with (
patch(f"{_PKG}.read_ledger_solved_today", return_value=None) as read,
patch(f"{_PKG}._fetch_leetcode_today", side_effect=OSError("refused")),
patch(f"{_PKG}.report_leetcode_incident"),
):
leetcode_solved_today(Config())
leetcode_solved_today(Config())
assert read.call_count == 2
class TestFetchLeetcodeToday:
"""The HTTP read itself."""
def test_an_unchecked_answer_is_an_error(self) -> None:
"""The server saying "I could not look" must not read as "no"."""
payload = json.dumps(
{"leetcode": {"checked": False, "solved_today": False, "reason": "dead"}}
).encode()
with (
patch(f"{_PKG}.http.client.HTTPConnection") as conn,
pytest.raises(ValueError, match="could not check"),
):
resp = conn.return_value.getresponse.return_value
resp.status = 200
resp.read.return_value = payload
_leetcode_bonus._fetch_leetcode_today("http://127.0.0.1:8771/api/status")
def test_a_non_200_is_an_error(self) -> None:
"""An error page is not a payload."""
with (
patch(f"{_PKG}.http.client.HTTPConnection") as conn,
pytest.raises(ValueError, match="status 500"),
):
resp = conn.return_value.getresponse.return_value
resp.status = 500
resp.reason = "Internal Server Error"
resp.read.return_value = b""
_leetcode_bonus._fetch_leetcode_today("http://127.0.0.1:8771/api/status")
def test_a_good_answer_is_read(self) -> None:
"""The happy path returns the flag."""
payload = json.dumps(
{"leetcode": {"checked": True, "solved_today": True, "reason": "1"}}
).encode()
with patch(f"{_PKG}.http.client.HTTPConnection") as conn:
resp = conn.return_value.getresponse.return_value
resp.status = 200
resp.read.return_value = payload
assert (
_leetcode_bonus._fetch_leetcode_today(
"http://127.0.0.1:8771/api/status"
)
is True
)