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

157 lines
5.2 KiB
Python

"""Tests for _bonus_incident: telling the user a bonus could not be checked.
The rate limit is the part worth pinning. The enforcer ticks frequently, so a
single stopped service would otherwise produce a notification every tick and an
unbounded log file -- which trains the user to ignore exactly the message that
means an hour is quietly missing.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._bonus_incident import (
report_leetcode_incident,
reset_reported,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_PKG = "steam_backlog_enforcer._bonus_incident"
@pytest.fixture(autouse=True)
def _clear_reported() -> Iterator[None]:
"""Keep reported-incident memory from leaking between tests.
Yields:
None, with the memo empty before and after.
"""
reset_reported()
yield
reset_reported()
class TestReporting:
"""All three channels fire, once."""
def test_it_notifies_logs_and_appends(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A first incident reaches every channel.
Args:
tmp_path: pytest's temporary directory.
caplog: pytest's log capture.
"""
log = tmp_path / "incidents.log"
with (
caplog.at_level(logging.WARNING),
patch(f"{_PKG}.INCIDENT_LOG", log),
patch(f"{_PKG}.notify_desktop_user") as notify,
):
report_leetcode_incident("ledger unreadable", fix="systemctl status x")
assert notify.call_count == 1
assert "ledger unreadable" in caplog.text
assert "systemctl status x" in log.read_text(encoding="utf-8")
def test_the_same_reason_is_reported_once_a_day(self, tmp_path: Path) -> None:
"""A ticking daemon must not produce a notification storm.
Args:
tmp_path: pytest's temporary directory.
"""
log = tmp_path / "incidents.log"
with (
patch(f"{_PKG}.INCIDENT_LOG", log),
patch(f"{_PKG}.notify_desktop_user") as notify,
):
for _ in range(5):
report_leetcode_incident("ledger unreadable", fix="check it")
assert notify.call_count == 1
assert len(log.read_text(encoding="utf-8").strip().splitlines()) == 1
def test_a_different_reason_still_gets_through(self, tmp_path: Path) -> None:
"""Suppression is per reason, not a blanket daily mute.
Args:
tmp_path: pytest's temporary directory.
"""
log = tmp_path / "incidents.log"
with (
patch(f"{_PKG}.INCIDENT_LOG", log),
patch(f"{_PKG}.notify_desktop_user") as notify,
):
report_leetcode_incident("ledger unreadable", fix="a")
report_leetcode_incident("status API unreachable", fix="b")
assert notify.call_count == 2
def test_a_repeat_is_still_traceable(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A suppressed repeat leaves a debug line, so it does not look cleared.
Args:
tmp_path: pytest's temporary directory.
caplog: pytest's log capture.
"""
log = tmp_path / "incidents.log"
with (
caplog.at_level(logging.DEBUG),
patch(f"{_PKG}.INCIDENT_LOG", log),
patch(f"{_PKG}.notify_desktop_user"),
):
report_leetcode_incident("ledger unreadable", fix="a")
caplog.clear()
report_leetcode_incident("ledger unreadable", fix="a")
assert "still unavailable" in caplog.text
class TestDegradedReporting:
"""A channel that fails must not take the others down with it."""
def test_an_unwritable_log_is_reported_not_raised(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The notification already went out; losing the file is not fatal.
Args:
tmp_path: pytest's temporary directory.
caplog: pytest's log capture.
"""
unwritable = tmp_path / "no-such-dir" / "incidents.log"
with (
caplog.at_level(logging.WARNING),
patch(f"{_PKG}.INCIDENT_LOG", unwritable),
patch(f"{_PKG}.notify_desktop_user") as notify,
):
report_leetcode_incident("ledger unreadable", fix="a")
assert notify.call_count == 1
assert "Could not append" in caplog.text
class TestResetReported:
"""The test hook itself, used by the autouse fixture above."""
def test_reset_lets_the_same_reason_fire_again(self, tmp_path: Path) -> None:
"""Without this, one test would mute the next.
Args:
tmp_path: pytest's temporary directory.
"""
log = tmp_path / "incidents.log"
with (
patch(f"{_PKG}.INCIDENT_LOG", log),
patch(f"{_PKG}.notify_desktop_user") as notify,
):
report_leetcode_incident("ledger unreadable", fix="a")
reset_reported()
report_leetcode_incident("ledger unreadable", fix="a")
assert notify.call_count == 2