screen-locker/screen_locker/tests/test_daily_limits_wiring.py
2026-11-01 20:16:50 +01:00

188 lines
6.7 KiB
Python

"""The wiring around rest days, the grace lift and the ladder accessors.
Live workout credit on a rest day, lift absorption in the shutdown adds,
the day-aware ``_earned`` accessors on an ``earned_time`` without the ladder
API (0.3.0), and the new CLI flags.
"""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import (
_ceiling,
_cli,
_earned,
_grace_floor,
_runnerup_tcx,
_workout_credit,
)
from screen_locker._shutdown import ShutdownMixin
from screen_locker._workout_credit import WorkoutCreditMixin
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_ISO = "2026-10-12" # a Monday
class Host(WorkoutCreditMixin, ShutdownMixin):
"""The real credit and add paths over an in-memory config."""
def __init__(self, tmp_path: Path, minutes: int, *, ok: bool = True) -> None:
self.config = (minutes, minutes, 300)
self.ok = ok
self.log_file = tmp_path / "log.json"
self.workout_data = {"type": "runnerup_verified"}
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
return self.config
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
) -> bool:
del restore
if self.ok:
self.config = (min(mon_wed, 1380), min(thu_sun, 1380), morning_end)
return self.ok
@pytest.fixture
def monday() -> Iterator[None]:
with (
patch("screen_locker._workout_credit.today_str", return_value=_ISO),
patch("screen_locker._shutdown.today_str", return_value=_ISO),
patch.object(_grace_floor, "today_str", return_value=_ISO),
):
yield
_COUNTED: list[dict[str, Any]] = [{"workout_data": {"type": "runnerup_verified"}}]
@pytest.mark.usefixtures("monday")
class TestRestDayCredit:
def test_the_first_real_workout_earns_nothing_more(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
host = Host(tmp_path, 20 * 60)
with (
patch.object(_workout_credit, "is_rest_day", return_value=True),
caplog.at_level(logging.INFO),
):
result = host._apply_credit_for_written_entry([])
assert host.config[0] == 20 * 60
assert not result.shutdown_adjusted
assert "already in tonight's time" in caplog.text
def test_a_second_one_earns_a_further_unit(self, tmp_path: Path) -> None:
host = Host(tmp_path, 20 * 60)
extra = _earned.extra_minutes(earned_time.WORKOUT, _grace_date())
with patch.object(_workout_credit, "is_rest_day", return_value=True):
result = host._apply_credit_for_written_entry(_COUNTED)
assert host.config[0] == 20 * 60 + extra
assert result.extra_bonus_delta == extra
def _grace_date() -> date:
return date.fromisoformat(_ISO)
@pytest.mark.usefixtures("monday")
class TestLiftAbsorption:
def test_adds_pay_from_the_lift_first(self, tmp_path: Path) -> None:
_grace_floor.save_lift(30)
host = Host(tmp_path, 19 * 60 + 55)
assert host._adjust_shutdown_time_by(25)
assert host.config[0] == 19 * 60 + 55
assert host._adjust_shutdown_time_by(25)
assert host.config[0] == 20 * 60 + 15
def test_a_failed_write_keeps_the_lift(self, tmp_path: Path) -> None:
_grace_floor.save_lift(30)
host = Host(tmp_path, 1195, ok=False)
assert not host._adjust_shutdown_time_by(25)
assert not host._adjust_shutdown_time_later()
assert _grace_floor.plan_absorb(0) == (0, 30)
def test_the_first_workout_is_day_aware(self, tmp_path: Path) -> None:
host = Host(tmp_path, 1140)
assert host._adjust_shutdown_time_later()
first = _earned.first_minutes(earned_time.WORKOUT, _grace_date())
assert host.config[0] == 1140 + first
class TestPreLadderEarnedTime:
"""On 0.3.0 the accessors fall back to the raw fields, and say so once."""
def test_raw_fields_without_the_ladder_api(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
for name in (
"shutdown_minutes_for",
"extra_shutdown_minutes_for",
"shutdown_ceiling_for",
"first_credit_at",
):
monkeypatch.delattr(earned_time, name, raising=False)
day = _grace_date()
workout = earned_time.WORKOUT
assert _earned.first_minutes(workout, day) == workout.shutdown_minutes
assert _earned.extra_minutes(workout, day) == workout.extra_shutdown_minutes
assert _ceiling.ceiling(day) == earned_time.SHUTDOWN_CEILING_MINUTES
_earned._warn_no_first_credit.cache_clear()
with caplog.at_level(logging.WARNING):
assert _earned.first_credit_time(earned_time.ANKI, day) is None
assert _earned.first_credit_time(earned_time.ANKI, day) is None
assert caplog.text.count("has no first_credit_at") == 1
def test_the_ladder_api_reads_the_ledger(self, tmp_path: Path) -> None:
if not hasattr(earned_time, "first_credit_at"):
pytest.skip("installed earned_time predates first_credit_at")
assert _earned.first_credit_time(earned_time.ANKI, _grace_date()) is None
def test_a_failed_tcx_parse_is_not_marked() -> None:
assert _runnerup_tcx._mark_walk(None, "x_Walking.tcx") is None
class _Bare:
"""What ``_headless_locker`` builds on: any class with a ``__dict__``."""
log_file: Path
workout_data: dict[str, str]
class TestCliFlags:
def test_declare_rest_day_dispatches_the_rest_of_argv(self) -> None:
with (
patch.object(_cli.logging, "basicConfig"),
patch.object(_cli, "run_declare_rest_day", return_value=0) as run,
patch.object(_cli.sys, "exit", side_effect=SystemExit) as sys_exit,
pytest.raises(SystemExit),
):
_cli.main(_Bare, ["screen_lock.py", "--declare-rest-day", "tomorrow"])
run.assert_called_once_with(["tomorrow"])
sys_exit.assert_called_once_with(0)
def test_backfill_dispatches_with_the_log(self) -> None:
with (
patch.object(_cli.logging, "basicConfig"),
patch.object(_cli, "run_backfill", return_value=0) as run,
patch.object(_cli.sys, "exit", side_effect=SystemExit),
pytest.raises(SystemExit),
):
_cli.main(
_Bare, ["screen_lock.py", "--backfill-workout-ledger", "--dry-run"]
)
log_file, argv = run.call_args.args
assert log_file.name == "log.json"
assert argv == ["--dry-run"]