mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +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 }}) (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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
172 lines
6.3 KiB
Python
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")
|