mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 12:27:33 +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
157 lines
5.2 KiB
Python
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
|