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
97 lines
2.4 KiB
Python
97 lines
2.4 KiB
Python
"""Shared builders for the LeetCode ledger tests.
|
|
|
|
Split out of ``test_leetcode_ledger.py`` to keep every file inside the 250-line
|
|
cap. The ``key_file`` fixture is imported by name into each test module, which
|
|
is what registers it there.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
LEDGER_PKG = "steam_backlog_enforcer._leetcode_ledger"
|
|
KEY = b"0123456789abcdef0123456789abcdef"
|
|
|
|
|
|
def sign(entry: dict[str, Any], key: bytes = KEY) -> dict[str, Any]:
|
|
"""Attach a genuine signature to an entry.
|
|
|
|
Args:
|
|
entry: The entry to sign.
|
|
key: The signing key.
|
|
|
|
Returns:
|
|
The same entry with an ``hmac`` field.
|
|
"""
|
|
payload = json.dumps(entry, sort_keys=True, separators=(",", ":"))
|
|
entry["hmac"] = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
|
|
return entry
|
|
|
|
|
|
def credit(*, when: datetime, entry_id: str = "ac:1") -> dict[str, Any]:
|
|
"""Build a signed credit entry submitted at *when*.
|
|
|
|
Args:
|
|
when: The submission time.
|
|
entry_id: The entry id, unique per credit.
|
|
|
|
Returns:
|
|
A signed credit entry.
|
|
"""
|
|
return sign(
|
|
{
|
|
"entry_id": entry_id,
|
|
"kind": "credit",
|
|
"day": when.date().isoformat(),
|
|
"created_at": when.isoformat(),
|
|
"amount": 1,
|
|
"device": "pc",
|
|
"detail": {
|
|
"source": "leetcode",
|
|
"submitted_at": str(int(when.timestamp())),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def write_ledger(path: Path, entries: list[dict[str, Any]]) -> Path:
|
|
"""Write a ledger file.
|
|
|
|
Args:
|
|
path: Directory to write into.
|
|
entries: The entries to store.
|
|
|
|
Returns:
|
|
The ledger path.
|
|
"""
|
|
ledger = path / "ledger.json"
|
|
ledger.write_text(json.dumps({"version": 1, "entries": entries}), encoding="utf-8")
|
|
return ledger
|
|
|
|
|
|
@pytest.fixture
|
|
def key_file(tmp_path: Path) -> Iterator[Path]:
|
|
"""Point the module at a throwaway signing key.
|
|
|
|
Args:
|
|
tmp_path: pytest's temporary directory.
|
|
|
|
Yields:
|
|
The key path, with the module patched to use it.
|
|
"""
|
|
path = tmp_path / "hmac.key"
|
|
path.write_bytes(KEY)
|
|
with patch(f"{LEDGER_PKG}.HMAC_KEY_FILE", path):
|
|
yield path
|