mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:33:49 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
200 lines
7.4 KiB
Python
200 lines
7.4 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 import _ledger_earners, _leetcode_bonus, _workout_budget
|
|
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
|
|
|
|
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"
|
|
_ANSWER = "steam_backlog_enforcer._budget_resolve.ledger_answer"
|
|
_HOUR = 3600.0
|
|
|
|
|
|
def _any_credit(_row: dict[str, object], _window: tuple[float, float]) -> bool:
|
|
"""Count every verified credit: the reader's own dating is earned_time's."""
|
|
return True
|
|
|
|
|
|
# A hypothetical earner: the real registry already holds anki, so the stand-in
|
|
# needs a name no gate uses.
|
|
PIANO = earned_time.Earner(
|
|
name="piano",
|
|
label="Piano",
|
|
gaming_minutes=30,
|
|
shutdown_minutes=30,
|
|
ledger=".local/share/piano_guard/ledger.json",
|
|
match=_any_credit,
|
|
)
|
|
|
|
|
|
@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.
|
|
"""
|
|
for module in (_ledger_earners, _workout_budget, _leetcode_bonus):
|
|
module.reset_cache()
|
|
yield
|
|
for module in (_ledger_earners, _workout_budget, _leetcode_bonus):
|
|
module.reset_cache()
|
|
|
|
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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}
|