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

188 lines
6.9 KiB
Python

"""Tests for _ledger_earners and the generic earner path in resolve_budget.
An earner registered in ``earned_time`` after workout, LeetCode and reading has
no module of its own here: :func:`ledger_answer` reads its signed ledger, and
``resolve_budget`` adds its term. These tests register a stand-in earner by
patching ``earned_time.EARNERS`` -- the registry ``resolve_budget`` reads, and
passes on to ``earned_time.resolve``, at call time.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING
from unittest.mock import call, patch
import earned_time
import pytest
from steam_backlog_enforcer._budget_resolve import BudgetResolution, resolve_budget
from steam_backlog_enforcer._ledger_earners import ledger_answer, 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
from steam_backlog_enforcer.tests._piano_earner import (
ANSWER,
HOUR,
LEETCODE,
PIANO,
WORKOUT,
earner_caches_cleared,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_PKG = "steam_backlog_enforcer._ledger_earners"
@pytest.fixture(autouse=True)
def _clear_caches() -> Iterator[None]:
"""Keep every earner memo from leaking answers between tests.
Yields:
None, with the caches empty before and after.
"""
with earner_caches_cleared():
yield
class TestLedgerAnswer:
"""One earner's answer from its ledger, memoised like the others."""
def test_an_earner_without_a_ledger_earns_nothing(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""No ledger and no reader here is "cannot check", and says so.
Args:
caplog: pytest's log capture.
"""
with caplog.at_level(logging.WARNING):
assert ledger_answer(replace(PIANO, ledger=None)) is None
assert "Earner Piano has no ledger" in caplog.text
def test_reads_the_ledger_under_the_home_directory(self, tmp_path: Path) -> None:
"""A signed credit in ``LEDGER_HOME / earner.ledger`` counts.
Args:
tmp_path: pytest's temporary directory (conftest's LEDGER_HOME).
"""
assert PIANO.ledger is not None
ledger_dir = (tmp_path / PIANO.ledger).parent
ledger_dir.mkdir(parents=True)
write_ledger(ledger_dir, [credit(when=datetime.now().astimezone())])
key = tmp_path / "hmac.key"
key.write_bytes(KEY)
with patch(f"{_PKG}.HMAC_KEY_FILE", key):
assert ledger_answer(PIANO) is True
@pytest.mark.parametrize("answer", [True, False])
def test_an_answer_is_cached(self, *, answer: bool) -> None:
"""A yes and a no are both served from the memo on the next tick.
Args:
answer: The answer the ledger gives.
"""
with patch("earned_time.done_today", return_value=answer) as read:
assert ledger_answer(PIANO) is answer
assert ledger_answer(PIANO) is answer
assert read.call_count == 1
def test_a_failure_is_not_cached(self) -> None:
"""A failed ledger read is retried on the next call."""
with patch("earned_time.done_today", return_value=None) as read:
assert ledger_answer(PIANO) is None
assert ledger_answer(PIANO) is None
assert read.call_count == 2
def test_reset_cache_forces_a_reread(self) -> None:
"""Resetting the cache makes the next call read the ledger again."""
with patch("earned_time.done_today", return_value=True) as read:
ledger_answer(PIANO)
reset_cache()
ledger_answer(PIANO)
assert read.call_count == 2
def _resolve(
*, answer: bool | None, earner: earned_time.Earner = PIANO
) -> BudgetResolution:
"""Resolve with ``earner`` registered, every real earner "no".
Args:
answer: What the extra earner's ledger says.
earner: The extra earner to register.
Returns:
The resolved budget.
"""
with (
pin_today(AFTER_CUT),
patch("earned_time.EARNERS", (*earned_time.EARNERS, earner)),
patch(WORKOUT, return_value=False),
patch(LEETCODE, return_value=False),
patch(
ANSWER, side_effect=lambda asked: answer if asked is earner else False
) as asked,
):
resolved = resolve_budget(Config())
asked.assert_any_call(earner)
return resolved
class TestAGenericEarnerInTheBudget:
"""A registered earner joins the sum with no code of its own here."""
def test_its_credit_adds_its_term(self) -> None:
"""A yes from its ledger adds the earner's term to the budget."""
resolved = _resolve(answer=True)
assert resolved.seconds == 4.5 * HOUR
assert resolved.earned_seconds["piano"] == 0.5 * HOUR
assert resolved.reason.endswith(", Piano credited")
def test_no_credit_adds_nothing(self) -> None:
"""A no adds nothing and the reason says it was not credited."""
resolved = _resolve(answer=False)
assert resolved.seconds == 4 * HOUR
assert resolved.earned_seconds["piano"] == 0.0
assert "no Piano credited" in resolved.reason
def test_unknown_adds_nothing_and_reads_differently(self) -> None:
"""An unknown answer adds nothing and reads as unknown."""
resolved = _resolve(answer=None)
assert resolved.seconds == 4 * HOUR
assert "Piano unknown" in resolved.reason
def test_its_penalty_lowers_the_base_by_what_it_pays_back(self) -> None:
"""Penalty, then reward: skipping it costs exactly its term.
Confirmed the day before, so its empty ledger fails closed: penalised
from ``penalty_from`` all the same.
"""
penalised = replace(
PIANO, penalty_from=AFTER_CUT, confirmed_on=AFTER_CUT - timedelta(days=1)
)
missed = _resolve(answer=False, earner=penalised)
earned = _resolve(answer=True, earner=penalised)
assert missed.base_seconds == 3.5 * HOUR
assert missed.seconds == 3.5 * HOUR
assert earned.seconds == 4 * HOUR
def test_the_built_in_earners_are_not_read_twice(self) -> None:
"""Only an earner with no dedicated reader goes through the ledger path."""
with (
pin_today(AFTER_CUT),
patch(WORKOUT, return_value=False),
patch(LEETCODE, return_value=False),
patch(ANSWER, return_value=False) as asked,
):
resolved = resolve_budget(Config())
dedicated = (earned_time.WORKOUT, earned_time.LEETCODE, earned_time.READING)
generic = [e for e in earned_time.EARNERS if e not in dedicated]
assert asked.call_args_list == [call(e) for e in generic]
assert set(resolved.earned_seconds) == {e.name for e in earned_time.EARNERS}