steam-backlog-enforcer/steam_backlog_enforcer/tests/test_ledger_units.py
Krzysztof kuhy Rudnicki d2565416aa Pylint gate covers tests, no suppressions
Tests are now linted by the 10.00 gate (ignore-paths dropped), with no
per-test disables. CtlServer/DaemonControl members the tests drive are
public (renamed, behaviour unchanged); SteamAPIClient._get takes its
params explicitly, as every caller already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:17:07 +01:00

156 lines
5.8 KiB
Python

"""Tests for counted gate earners (the Automation tutor) in the gaming budget.
A counted gate pays per unit -- one verified active tutor minute each --
so :func:`ledger_units` reads its unit count for the day and
``resolve_budget`` turns it into gaming time through the registry. A stand-in
counted earner is registered by patching ``earned_time.EARNERS``, which both
the installed earned_time and the cutover one (``earners_for``) read.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import logging
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import patch
import earned_time
import pytest
from steam_backlog_enforcer import _ledger_earners
from steam_backlog_enforcer._budget_resolve import BudgetResolution, resolve_budget
from steam_backlog_enforcer._budget_view import _most
from steam_backlog_enforcer._ledger_earners import ledger_units, reset_cache
from steam_backlog_enforcer.config import Config
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today
from steam_backlog_enforcer.tests._ledger_fixtures import KEY, credit, write_ledger
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_PKG = "steam_backlog_enforcer._ledger_earners"
_WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
_LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
_UNITS = "steam_backlog_enforcer._budget_resolve.ledger_units"
_ANSWER = "steam_backlog_enforcer._budget_resolve.ledger_answer"
def _any_credit(_row: dict[str, object], _window: tuple[float, float]) -> bool:
return True
BLOCKS = earned_time.Earner(
name="blocks",
label="Blocks",
gaming_minutes=15,
shutdown_minutes=13,
kind="counted",
ledger=".local/share/blocks_guard/ledger.json",
match=_any_credit,
)
@pytest.fixture(autouse=True)
def _clear() -> Iterator[None]:
"""Start and end every test with empty memos.
Yields:
None.
"""
reset_cache()
yield
reset_cache()
class TestLedgerUnits:
"""ledger_units counts signed ledger rows and caches the count."""
def test_no_ledger_is_none_and_says_so(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""An earner without a ledger yields None and logs a warning."""
with caplog.at_level(logging.WARNING):
assert ledger_units(replace(BLOCKS, ledger=None)) is None
assert "Earner Blocks has no ledger" in caplog.text
def test_counts_the_signed_rows_under_home(self, tmp_path: Path) -> None:
"""Each signed credit row under HOME counts as one unit."""
assert BLOCKS.ledger is not None
ledger_dir = (tmp_path / BLOCKS.ledger).parent
ledger_dir.mkdir(parents=True)
now = datetime.now().astimezone()
write_ledger(ledger_dir, [credit(when=now, entry_id=f"s-b{n}") for n in (1, 2)])
key = tmp_path / "hmac.key"
key.write_bytes(KEY)
with patch(f"{_PKG}.HMAC_KEY_FILE", key):
assert ledger_units(BLOCKS) == 2
def test_a_count_is_cached_until_its_ttl(self) -> None:
"""A count is reused until its TTL runs out."""
with (
patch("earned_time.credit_units", return_value=3) as read,
patch(f"{_PKG}.time.monotonic", side_effect=[0.0, 30.0, 61.0]),
):
assert [ledger_units(BLOCKS) for _ in range(3)] == [3, 3, 3]
assert read.call_count == 2
def test_a_failure_is_not_cached(self) -> None:
"""A failed read is retried, not cached."""
with patch("earned_time.credit_units", return_value=None) as read:
assert ledger_units(BLOCKS) is None
assert ledger_units(BLOCKS) is None
assert read.call_count == 2
def test_reset_cache_drops_counts(self) -> None:
"""reset_cache forces the next call to reread."""
with patch("earned_time.credit_units", return_value=1) as read:
ledger_units(BLOCKS)
_ledger_earners.reset_cache()
ledger_units(BLOCKS)
assert read.call_count == 2
def _resolve(units: int | None) -> BudgetResolution:
with (
pin_today(AFTER_CUT),
patch("earned_time.EARNERS", (*earned_time.EARNERS, BLOCKS)),
patch(_WORKOUT, return_value=False),
patch(_LEETCODE, return_value=False),
patch(_ANSWER, return_value=False),
patch(_UNITS, return_value=units) as asked,
):
resolved = resolve_budget(Config())
asked.assert_called_once_with(BLOCKS)
return resolved
class TestACountedGateInTheBudget:
"""Ledger units flow through the registry into the budget reason."""
def test_units_earn_through_the_registry(self) -> None:
"""Two units earn the registry minutes for two and say x2."""
resolved = _resolve(2)
assert resolved.earned_seconds["blocks"] == BLOCKS.gaming_for(2) * 60.0
assert resolved.reason.endswith(", Blocks credited x2")
def test_one_unit_reads_like_a_flat_credit(self) -> None:
"""One unit reads like a flat credit, without a multiplier."""
assert _resolve(1).reason.endswith(", Blocks credited")
def test_none_and_unknown(self) -> None:
"""Zero units and an unknown count are both named in the reason."""
assert "no Blocks credited" in _resolve(0).reason
assert "Blocks unknown" in _resolve(None).reason
def test_view_lists_what_a_full_day_pays() -> None:
"""The view shows the capped maximum, or the flat minutes without units."""
# Duck-typed stand-ins: ``plain`` is a pre-0.5 earner without units.
capped = SimpleNamespace(gaming_minutes=15, max_gaming_minutes=60)
plain = SimpleNamespace(gaming_minutes=30)
assert _most(cast("earned_time.Earner", capped)) == 60
assert _most(cast("earned_time.Earner", plain)) == 30