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

165 lines
5.4 KiB
Python

"""Tests for _ledger_earners.first_credits: maturity only delays a penalty.
A stand-in earner (``piano``) is registered with a penalty. Its signed ledger
lives under the suite's redirected ``LEDGER_HOME``; whether it has ever paid
out decides the first day its cut applies (earned_time 0.6.0), and on an
earned_time without ``maturity`` the budget resolves exactly as 0.5.0 did.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, time, timedelta
from typing import TYPE_CHECKING
from unittest.mock import patch
import earned_time
import pytest
from steam_backlog_enforcer import _ledger_earners
from steam_backlog_enforcer._budget_resolve import resolve_budget
from steam_backlog_enforcer._ledger_earners import first_credits
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,
WORKOUT,
earner_caches_cleared,
)
from steam_backlog_enforcer.tests._piano_earner import (
PIANO as BASE_PIANO,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import date
from pathlib import Path
_DAY = timedelta(days=1)
# Penalised since well before the suite's pinned day, never confirmed by kuhy:
# without a real credit it is a new gate and costs nothing.
PIANO = replace(BASE_PIANO, penalty_from=AFTER_CUT - 5 * _DAY)
@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
@pytest.fixture(name="key")
def fixture_key(tmp_path: Path) -> Iterator[Path]:
"""Sign and verify the stand-in's ledger with a throwaway key.
Args:
tmp_path: pytest's temporary directory (also ``LEDGER_HOME``).
Yields:
The key path, patched into ``_ledger_earners``.
"""
path = tmp_path / "hmac.key"
path.write_bytes(KEY)
with patch.object(_ledger_earners, "HMAC_KEY_FILE", path):
yield path
def _credit_on(home: Path, day: date) -> None:
"""Write the stand-in's ledger with one signed credit at noon on ``day``."""
ledger = home / str(PIANO.ledger)
ledger.parent.mkdir(parents=True)
when = datetime.combine(day, time(12)).astimezone()
write_ledger(ledger.parent, [credit(when=when, entry_id="piano:1")])
def _base(day: date) -> float:
"""``resolve_budget``'s floor on ``day`` with ``piano`` registered, all "no"."""
with (
pin_today(day),
patch("earned_time.EARNERS", (*earned_time.EARNERS, PIANO)),
patch(WORKOUT, return_value=False),
patch(LEETCODE, return_value=False),
patch(ANSWER, return_value=False),
):
return resolve_budget(Config()).base_seconds
class TestTheMap:
"""Only penalised, ledger-backed earners, keyed by name."""
def test_maps_each_penalised_reader_to_its_first_credit(
self, tmp_path: Path, key: Path
) -> None:
"""A bonus, a ledger-less earner and a matcher-less one are left out.
Args:
tmp_path: The suite's ``LEDGER_HOME``.
key: The patched signing key.
"""
assert key.exists()
_credit_on(tmp_path, AFTER_CUT - _DAY)
registry = (
PIANO,
replace(PIANO, name="bonus", penalty_from=None),
replace(PIANO, name="no-ledger", ledger=None),
replace(PIANO, name="no-match", match=None),
)
assert first_credits(registry, AFTER_CUT) == {"piano": AFTER_CUT - _DAY}
def test_an_unreadable_ledger_is_no_first_credit(self) -> None:
"""No ledger and no key on record: ``None``, which resolve treats closed."""
assert first_credits((PIANO,), AFTER_CUT) == {"piano": None}
def test_an_old_earned_time_gets_no_map(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without ``maturity`` (< 0.6) there is nothing to pass on.
Args:
monkeypatch: pytest's attribute patcher.
"""
monkeypatch.delattr(earned_time, "maturity")
assert first_credits((PIANO,), AFTER_CUT) is None
class TestTheBudget:
"""The cut waits for the day after the first real credit."""
def test_a_gate_that_never_paid_out_costs_nothing(self) -> None:
"""An earner with no credit ever leaves the floor uncut."""
assert _base(AFTER_CUT) == 4 * HOUR
def test_the_cut_starts_the_day_after_the_first_credit(
self, tmp_path: Path, key: Path
) -> None:
"""Credited on the pinned day: free that day, penalised the next.
Args:
tmp_path: The suite's ``LEDGER_HOME``.
key: The patched signing key.
"""
assert key.exists()
_credit_on(tmp_path, AFTER_CUT)
assert _base(AFTER_CUT) == 4 * HOUR
assert _base(AFTER_CUT + _DAY) == 3.5 * HOUR
def test_an_old_earned_time_resolves_as_before(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No ``maturity``, no ``first_credits``: ``penalty_from`` alone cuts.
Args:
monkeypatch: pytest's attribute patcher.
"""
monkeypatch.delattr(earned_time, "maturity")
assert _base(AFTER_CUT) == 3.5 * HOUR