screen-locker/screen_locker/tests/test_shutdown_reading.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

185 lines
7.5 KiB
Python

"""Tests for the reading hour as a term of the shutdown derivation.
Covers the base cut it pays for (20 -> 19 from 2026-10-01), the daily reset
folding the hour in and stamping it, and the live pass that adds it once for a
reading credit landing after the reset. Mirrors test_shutdown_leetcode.py; the
pass over every flat earner is in test_shutdown_flat_earners.py.
"""
from __future__ import annotations
from datetime import date, datetime
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import earned_time
import pytest
from screen_locker._day import today_str
from screen_locker._earned import first_credits, gate_earners, hhmm, registry
from screen_locker._shutdown_base import (
_apply_flat_bonus,
apply_flat_bonuses_if_new,
base_minutes,
reset_to_base_if_new_day,
)
from screen_locker.tests._earned_fixtures import answering
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
# Terms the reset log spells out by hand; every later earner follows them.
_NAMED = frozenset({"leetcode", "reading"})
def _mixin(*, adjust_ok: bool = True) -> MagicMock:
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (base_minutes(), base_minutes(), 300)
mixin._write_shutdown_config.return_value = True
mixin._adjust_shutdown_time_by.return_value = adjust_ok
return mixin
def apply_reading_bonus_if_new(state: Path, mixin: MagicMock) -> bool:
"""The live pass for the reading earner alone."""
return _apply_flat_bonus(state, mixin, earned_time.READING)
@pytest.fixture(name="answers")
def answers_fixture() -> Iterator[tuple[dict[str, bool | None], MagicMock]]:
"""(answers, earned_today mock): every flat earner says no until set."""
given: dict[str, bool | None] = {"leetcode": False, "reading": False}
with answering(given) as mock:
yield given, mock
class TestBaseHour:
"""base_minutes lowers the base evening shutdown once the reading cut starts."""
def test_day_before_the_cut_keeps_twenty(self) -> None:
"""Before the reading cut day the base stays at 20:00."""
assert base_minutes(date(2026, 9, 30)) == 20 * 60
def test_cut_day_is_nineteen(self) -> None:
"""On the reading cut day the base is 19:00."""
assert earned_time.READING.penalty_from == date(2026, 10, 1)
assert base_minutes(date(2026, 10, 1)) == 19 * 60
def test_long_after_the_cut_stays_lowered(self) -> None:
"""No drift back to 20:00: the registry's base for the day holds.
From earned_time's sleep ladder (2026-10-10) the floor is the ceiling
minus every earner's first unit, not 20:00 minus the cuts, so the day
is priced by the registry and only the "stays lowered" part is ours.
The Anki waiver (``ANKI_WAIVED_FROM``) raises that floor to 19:50, so
"lowered" means below the uncut 20:00, not at or below 19:00; a gate
that never paid out (the tutor) spares it (earned_time 0.6.1).
"""
later = date(2030, 1, 1)
starts = first_credits(registry(later), later)
base = earned_time.base_for(later, registry(later), first_credits=starts)
assert base_minutes(later) == base.shutdown_minutes
assert base.shutdown_minutes < base_minutes(date(2026, 9, 30))
def test_default_is_the_local_today(self) -> None:
"""Called without a day, base_minutes uses the local today."""
assert base_minutes() == base_minutes(datetime.now().astimezone().date())
class TestResetIncludesReading:
"""The daily reset folds a paid reading hour in as a term and stamps it."""
@pytest.mark.usefixtures("pre_ladder")
def test_reset_writes_base_plus_reading_and_stamps_it(
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
) -> None:
"""A reading credit raises the reset shutdown by one hour and stamps reading only."""
answers[0]["reading"] = True
state = tmp_path / "state.json"
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_minutes() + 60, base_minutes() + 60, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
"reading_bonus_date": today_str(),
}
@pytest.mark.usefixtures("pre_ladder")
def test_reset_with_both_flat_hours_stamps_both(
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
) -> None:
"""Earning both flat hours adds two hours and stamps both, leaving the live pass idle."""
answers[0]["leetcode"] = True
answers[0]["reading"] = True
state = tmp_path / "state.json"
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_minutes() + 120, base_minutes() + 120, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
"leetcode_bonus_date": today_str(),
"reading_bonus_date": today_str(),
}
apply_flat_bonuses_if_new(state, mixin)
mixin._adjust_shutdown_time_by.assert_not_called()
def test_reset_without_the_tutor_stops_an_hour_below_the_ceiling(
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
) -> None:
"""Nine workouts still pay one rung; the confirmed tutor's 60 is unpaid."""
answers[0]["leetcode"] = True
answers[0]["reading"] = True
mixin = _mixin()
with patch("screen_locker._shutdown_target.day_credit_count", return_value=9):
reset_to_base_if_new_day(
tmp_path / "state.json", mixin, log_file=tmp_path / "log.json"
)
mixin._write_shutdown_config.assert_called_once_with(
1320, 1320, 300, restore=True
)
def test_unknown_reading_earns_nothing_warns_and_is_not_stamped(
self,
tmp_path: Path,
answers: tuple[dict[str, bool | None], MagicMock],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The live pass must ask again: a stamp would forfeit the day's hour."""
answers[0]["reading"] = None
state = tmp_path / "state.json"
mixin = _mixin()
with caplog.at_level("WARNING"):
assert reset_to_base_if_new_day(state, mixin) is True
assert "reading state could not be checked" in caplog.text
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base, base, 300, restore=True
)
assert json.loads(state.read_text()) == {"last_reset_date": today_str()}
@pytest.mark.usefixtures("pre_ladder")
def test_reset_logs_every_term(
self,
tmp_path: Path,
answers: tuple[dict[str, bool | None], MagicMock],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The reset log message spells out every term of the derivation."""
answers[0]["reading"] = True
with caplog.at_level("INFO"):
reset_to_base_if_new_day(tmp_path / "state.json", _mixin())
base = base_minutes()
later = "".join(
f" + 0h {e.label}" for e in gate_earners() if e.name not in _NAMED
)
assert (
f"Daily base reset: {hhmm(base + 60)} (base {hhmm(base)} + 0h workout + "
f"0h LeetCode + 1h reading{later} already earned today)."
) in caplog.text