screen-locker/screen_locker/tests/test_first_credits.py
Krzysztof kuhy Rudnicki aa3e290391 Document every test, split files at the cap
Real one-line docstrings stating what each test pins down. Files that
would pass 250 lines moved whole classes into _part2 siblings
(sick_panel, earned, poke_server, shutdown_reading,
manual_workout_dialog).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00

169 lines
5.9 KiB
Python

"""Tests for ``_earned.first_credits``: maturity only delays a penalty.
A stand-in earner (``piano``) is registered with a penalty from well before
the pinned day. Its signed ledger lives under the suite's redirected
``LEDGER_HOME``; whether it has ever paid out decides whether its cut applies
(earned_time 0.6.0). Every date is fixed and "today" is pinned on
``_earned.today_str``, so the real calendar cannot move a result.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime, time, timedelta
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _earned
from screen_locker._earned import first_credits, registry
from screen_locker._shutdown_base import base_minutes
from screen_locker._shutdown_target import DayInputs, derive, gather
from screen_locker.tests._earned_fixtures import (
EXTRA,
credit,
register,
signing_key,
write_ledger,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
# Before the sleep ladder (2026-10-10), where a penalty still lowers the
# shutdown base; before the Anki waiver too, so the registry is EARNERS.
_DAY = date(2026, 10, 8)
_ONE = timedelta(days=1)
def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
"""Every verified credit counts: the dating is earned_time's own."""
del row, window
return True
# Penalised for days already, never confirmed by kuhy: a new gate.
PIANO = earned_time.Earner(
name="piano",
label="Piano",
gaming_minutes=30,
shutdown_minutes=30,
ledger=".local/share/piano_guard/ledger.json",
match=_any_credit,
penalty_from=_DAY - 5 * _ONE,
)
@pytest.fixture
def today() -> Iterator[None]:
"""Pin "today" to ``_DAY`` for the history check.
Yields:
None, with ``_earned.today_str`` answering ``_DAY``.
"""
with patch.object(_earned, "today_str", return_value=_DAY.isoformat()):
yield
@pytest.fixture
def piano(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]:
"""Register ``piano`` and sign its ledger with a throwaway key.
Args:
monkeypatch: Patches the registries.
tmp_path: Where the key is written.
Yields:
None, with ``piano`` in every registry and the key patched.
"""
register(monkeypatch, PIANO)
with signing_key(tmp_path):
yield
def _paid(day: date) -> None:
"""``piano`` paid out once, at noon on ``day``."""
write_ledger(PIANO, [credit(PIANO, datetime.combine(day, time(12)).astimezone())])
@pytest.mark.usefixtures("today", "piano")
class TestTheMap:
"""first_credits maps penalised readers to the day they first paid out."""
def test_each_penalised_reader_maps_to_its_first_credit(self) -> None:
"""A bonus, a ledger-less and a matcher-less earner are left out."""
_paid(_DAY - _ONE)
never = replace(PIANO, name="never", ledger=".local/share/never/l.json")
earners = (
PIANO,
never,
EXTRA,
replace(PIANO, name="bare", ledger=None, match=None),
replace(PIANO, name="blind", match=None),
)
assert first_credits(earners, _DAY) == {"piano": _DAY - _ONE, "never": None}
def test_a_future_day_is_resolved_too(self) -> None:
"""A day after today is resolved as well, giving no first credit yet."""
assert first_credits((PIANO,), _DAY + _ONE) == {"piano": None}
def test_history_is_never_re_resolved(self) -> None:
"""A past day returns None so history is priced on penalty_from alone."""
assert first_credits((PIANO,), _DAY - _ONE) is None
def test_an_earned_time_without_maturity_says_so(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An earned_time without maturity yields None and warns 'no maturity'."""
monkeypatch.delattr(earned_time, "maturity")
_earned._warn_no_maturity.cache_clear()
with caplog.at_level(logging.WARNING):
assert first_credits((PIANO,), _DAY) is None
_earned._warn_no_maturity.cache_clear()
assert "no maturity" in caplog.text
class TestTheBase:
"""``base_minutes`` and the derived target agree on who is penalised."""
@pytest.mark.usefixtures("today")
def test_only_a_gate_that_paid_out_is_penalised(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Never paid out: free. Paid yesterday: cut. Paid today: from tomorrow."""
with signing_key(tmp_path):
without = base_minutes(_DAY)
register(monkeypatch, PIANO)
assert base_minutes(_DAY) == without
assert gather(_DAY, None).resolution.base.shutdown_minutes == without
_paid(_DAY - _ONE)
assert base_minutes(_DAY) == without - PIANO.shutdown_minutes
_paid(_DAY)
assert base_minutes(_DAY) == without
@pytest.mark.usefixtures("piano")
def test_a_past_day_keeps_its_penalty_from_base(self) -> None:
"""History: priced on ``penalty_from`` alone, as it was enforced."""
later = (_DAY + _ONE).isoformat()
strict = earned_time.base_for(_DAY, registry(_DAY)).shutdown_minutes
with patch.object(_earned, "today_str", return_value=later):
assert base_minutes(_DAY) == strict
@pytest.mark.usefixtures("piano")
class TestDerive:
"""The derived target honours first-credit maturity."""
def test_first_credits_reach_the_resolution(self) -> None:
"""Supplying first credits lifts the penalty from the derived base."""
answers = {e.name: False for e in registry(_DAY)}
lenient = derive(DayInputs(_DAY, answers, first_credits={"piano": None}))
strict = derive(DayInputs(_DAY, answers))
assert lenient.resolution.base.shutdown_minutes == (
strict.resolution.base.shutdown_minutes + PIANO.shutdown_minutes
)