screen-locker/screen_locker/tests/test_grace_floor.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

202 lines
7.3 KiB
Python

"""Tests for the grace floor, its lift bookkeeping and the derived target."""
from __future__ import annotations
import dataclasses
from datetime import date, datetime, time, timedelta
import json
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _earned, _grace_floor
from screen_locker._grace_floor import (
GraceFloorMixin,
first_done_at,
grace_for,
plan_absorb,
save_lift,
workout_done_at,
)
from screen_locker.tests._sick_helpers import mark_sick
if TYPE_CHECKING:
from pathlib import Path
_DAY = date(2026, 10, 10)
_ISO = _DAY.isoformat()
_FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False}
def at(hhmm: str, day: date = _DAY) -> float:
hours, minutes = map(int, hhmm.split(":"))
return datetime.combine(day, time(hours, minutes)).astimezone().timestamp()
def log_with(tmp_path: Path, entries: list[dict[str, Any]]) -> Path:
log = tmp_path / "log.json"
log.write_text(json.dumps({_ISO: entries}))
return log
class TestWhenTheDayStarted:
def test_the_first_counted_workout_by_its_own_end(self, tmp_path: Path) -> None:
log = log_with(
tmp_path,
[
{"workout_data": {"type": "relaxed_day_skip"}, "timestamp": "x"},
{"workout_data": {"type": "manual_workout", "end_time": "18:40"}},
{
"workout_data": {
"type": "runnerup_verified",
"completed_at": at("17:00"),
}
},
],
)
assert workout_done_at(log, _DAY) == at("17:00")
def test_no_workout_is_none(self, tmp_path: Path) -> None:
assert workout_done_at(log_with(tmp_path, []), _DAY) is None
def test_the_earliest_earner_on_the_day_wins(self, tmp_path: Path) -> None:
log = log_with(
tmp_path,
[{"workout_data": {"type": "manual_workout", "end_time": "19:30"}}],
)
times = {"reading": at("18:55"), "leetcode": at("23:00", _DAY - timedelta(1))}
with patch.object(
_grace_floor,
"first_credit_time",
side_effect=lambda e, _d: times.get(e.name),
):
assert first_done_at(_DAY, log) == at("18:55")
assert first_done_at(_DAY, None) == at("18:55")
def test_a_rest_day_skips_the_workout_ledger(self) -> None:
"""Its rest row is stamped at midnight, which is not a completion."""
ledgered = dataclasses.replace(earned_time.WORKOUT, ledger="w.json")
with (
patch.object(earned_time, "WORKOUT", ledgered),
patch.object(_grace_floor, "first_credit_time", return_value=at("00:00")),
):
assert first_done_at(_DAY, None) == at("00:00")
with patch.object(_grace_floor, "gate_earners", return_value=()):
assert first_done_at(_DAY, None, rest_day=True) is None
def test_without_a_workout_ledger_only_the_log_answers(self) -> None:
bare = earned_time.Earner(
name="workout", label="workout", gaming_minutes=0, shutdown_minutes=0
)
with (
patch.object(earned_time, "WORKOUT", bare),
patch.object(_grace_floor, "first_credit_time", return_value=None),
):
assert first_done_at(_DAY, None) is None
class TestGraceFor:
def test_an_hour_after_the_first_task(self) -> None:
assert grace_for(at("18:55"), _DAY) == 19 * 60 + 55
def test_capped_at_the_ceiling(self) -> None:
assert grace_for(at("22:40"), _DAY) == _earned.ceiling(_DAY)
def test_nothing_done_or_another_day_is_no_floor(self) -> None:
assert grace_for(None, _DAY) is None
assert grace_for(at("12:00", _DAY - timedelta(1)), _DAY) is None
class TestLift:
def test_absorb_pays_from_the_lift_first(self) -> None:
with patch.object(_grace_floor, "today_str", return_value=_ISO):
assert plan_absorb(30) == (30, 0)
save_lift(45)
assert plan_absorb(30) == (0, 15)
assert plan_absorb(60) == (15, 0)
def test_yesterdays_lift_is_spent(self) -> None:
with patch.object(_grace_floor, "today_str", return_value="2026-10-09"):
save_lift(45)
with patch.object(_grace_floor, "today_str", return_value=_ISO):
assert plan_absorb(30) == (30, 0)
def test_an_unreadable_state_is_no_lift(
self, caplog: pytest.LogCaptureFixture
) -> None:
_grace_floor.GRACE_STATE_FILE.write_text("{bad")
with caplog.at_level(logging.WARNING):
assert plan_absorb(10) == (10, 0)
assert "unreadable" in caplog.text
def test_a_failed_save_is_logged(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
save_lift(5, tmp_path / "missing-dir" / "g.json")
assert "Could not record the grace lift" in caplog.text
class Host(GraceFloorMixin):
"""The mixin over an in-memory config."""
def __init__(self, config: tuple[int, int, int] | None, *, ok: bool = True) -> None:
self.config = config
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
@pytest.fixture
def saturday() -> Any:
with patch.object(_grace_floor, "today_str", return_value=_ISO):
yield
@pytest.mark.usefixtures("saturday")
class TestApply:
def test_lifts_to_the_floor_and_records_the_lift(self) -> None:
host = Host((19 * 60 + 25, 19 * 60 + 25, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is True
assert host.config == (19 * 60 + 55, 19 * 60 + 55, 300)
assert plan_absorb(0) == (0, 30)
def test_already_above_the_floor(self) -> None:
host = Host((21 * 60, 21 * 60, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
def test_no_first_task_no_floor(self) -> None:
with patch.object(_grace_floor, "first_done_at", return_value=None):
assert Host((1140, 1140, 300))._apply_grace_floor(None) is False
def test_unreadable_config(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
assert Host(None)._apply_grace_floor(None, first_done=at("18:55")) is False
assert "config unreadable" in caplog.text
def test_failed_write(self, caplog: pytest.LogCaptureFixture) -> None:
host = Host((1140, 1140, 300), ok=False)
with caplog.at_level(logging.WARNING):
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
assert "failed to write" in caplog.text
assert plan_absorb(0) == (0, 0)
def test_a_sick_day_has_no_floor(self) -> None:
"""A sick day has the 20:00 sick floor instead."""
mark_sick(_DAY)
host = Host((18 * 60, 18 * 60, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
assert host.config == (18 * 60, 18 * 60, 300)