screen-locker/screen_locker/tests/test_late_credit.py
Krzysztof kuhy Rudnicki 46393fa866
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 }}) (flutter, flutter-analyze, ) (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,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
late credits: a credit at/after tonight's shutdown sets it to min(23:00, its own time + reward); grace floor removed (also on sick days)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:14:04 +02:00

172 lines
6.3 KiB
Python

"""A credit that lands after tonight's shutdown restarts the clock from it.
kuhy, 2026-10-17: shutting down must never make a finished task impossible to
submit. A credit timed at or after tonight's shutdown moves it to the
credit's own time plus its reward (capped at the ceiling), so LeetCode done
at 19:00 after an 18:00 shutdown means 20:00. Anchored on the credit's time,
never the sync's: a workout done at 17:00 and uploaded at 19:00 is not late.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _grace_floor, _late_credit, _shutdown, _sick_floor
from screen_locker._earned import first_minutes
from screen_locker._grace_floor import plan_absorb, save_lift
from screen_locker._shutdown import ShutdownMixin
from screen_locker.tests._sick_helpers import mark_sick
if TYPE_CHECKING:
from collections.abc import Iterator
# Captured before conftest pins it to noon for every test.
_REAL_NOW = _late_credit._now
_DAY = date(2026, 10, 17) # a Saturday: the Thu-Sun band is tonight's
_ISO = _DAY.isoformat()
def at(hhmm: str, day: date = _DAY) -> float:
"""Unix time of ``hhmm`` local on ``day``."""
hour, minute = (int(p) for p in hhmm.split(":"))
return datetime(day.year, day.month, day.day, hour, minute).astimezone().timestamp()
def host_workout_minutes() -> int:
return first_minutes(earned_time.WORKOUT, _DAY)
def m(hhmm: str) -> int:
hour, minute = (int(p) for p in hhmm.split(":"))
return hour * 60 + minute
class Host(ShutdownMixin):
"""The real add paths over an in-memory config."""
def __init__(self, tonight: str, *, ok: bool = True) -> None:
self.config: tuple[int, int, int] = (m(tonight), m(tonight), 300)
self.ok = ok
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:
assert restore
if self.ok:
self.config = (mon_wed, thu_sun, morning_end)
return self.ok
@property
def tonight(self) -> int:
return self.config[1]
@pytest.fixture(autouse=True)
def _on_the_day() -> Iterator[Any]:
with (
patch.object(_sick_floor, "today_str", return_value=_ISO),
patch.object(_grace_floor, "today_str", return_value=_ISO),
patch.object(_shutdown, "today_str", return_value=_ISO),
patch.object(_late_credit, "today_str", return_value=_ISO),
):
yield
class TestLateCredit:
def test_a_credit_before_the_shutdown_adds_as_before(self) -> None:
host = Host("20:00")
assert host._adjust_shutdown_time_by(60, at=at("18:30")) is True
assert host.tonight == m("21:00")
def test_a_credit_after_the_shutdown_counts_from_its_own_time(self) -> None:
host = Host("18:00")
assert host._adjust_shutdown_time_by(60, at=at("19:00")) is True
assert host.tonight == m("20:00")
assert host.config[0] == m("20:00") # both bands, like the floors
def test_a_credit_exactly_at_the_shutdown_is_late(self) -> None:
"""The check script locks at ``now >= shutdown``."""
host = Host("19:00")
host._adjust_shutdown_time_by(60, at=at("19:00"))
assert host.tonight == m("20:00")
def test_capped_at_the_ceiling(self) -> None:
host = Host("22:30")
host._adjust_shutdown_time_by(120, at=at("22:45"))
assert host.tonight == m("23:00")
def test_after_the_ceiling_nothing_moves_past_it(self) -> None:
host = Host("23:00")
host._adjust_shutdown_time_by(60, at=at("23:10"))
assert host.tonight == m("23:00")
def test_a_late_upload_is_not_a_late_credit(self) -> None:
"""Done at 17:00, synced at 19:00: 18:00 + 2 h, not 19:00 + 2 h."""
host = Host("18:00")
assert host._adjust_shutdown_time_later(at=at("17:00")) is True
assert host.tonight == m("18:00") + host_workout_minutes()
def test_a_late_workout_counts_from_its_end(self) -> None:
host = Host("18:00")
host._adjust_shutdown_time_later(at=at("19:00"))
assert host.tonight == min(m("23:00"), m("19:00") + host_workout_minutes())
def test_a_credit_timed_on_another_day_is_never_late(self) -> None:
host = Host("18:00")
host._adjust_shutdown_time_by(60, at=at("19:00", date(2026, 10, 16)))
assert host.tonight == m("19:00")
def test_no_time_means_now(self) -> None:
host = Host("18:00")
with patch.object(_late_credit, "_now", return_value=at("19:30")):
host._adjust_shutdown_time_by(60)
assert host.tonight == m("20:30")
def test_now_is_the_wall_clock(self) -> None:
with patch.object(_late_credit.time, "time", return_value=123.0):
assert _REAL_NOW() == 123.0
def test_a_late_credit_clears_the_lift(self) -> None:
"""Past the shutdown the floor is spent: nothing is left to absorb."""
save_lift(30)
host = Host("18:00")
host._adjust_shutdown_time_by(60, at=at("19:00"))
assert host.tonight == m("20:00")
assert plan_absorb(60) == (60, 0)
def test_a_failed_write_keeps_the_lift(self) -> None:
save_lift(30)
host = Host("18:00", ok=False)
assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False
assert plan_absorb(0) == (0, 30)
def test_unreadable_config_fails(self) -> None:
host = Host("18:00")
with patch.object(Host, "_read_shutdown_config", return_value=None):
assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False
class TestLateOnASickDay:
def test_late_after_the_sick_target(self) -> None:
mark_sick(_DAY)
host = Host("20:00")
with patch.object(_sick_floor, "sick_target", return_value=m("20:00")):
assert host._adjust_shutdown_time_by(60, at=at("21:00")) is True
assert host.tonight == m("22:00")
def test_not_late_raises_to_the_target_only(self) -> None:
mark_sick(_DAY)
host = Host("21:00")
with patch.object(_sick_floor, "sick_target", return_value=m("22:00")):
host._adjust_shutdown_time_by(60, at=at("15:00"))
assert host.tonight == m("22:00")