screen-locker/screen_locker/tests/test_earned.py
Krzysztof kuhy Rudnicki 373a075606 Shutdown: walks as workout, rest days, grace floor, workout ledger (earned-time 0.4.0)
- A day's RunnerUp walks (_Walking.tcx) count once their moving time sums to 40 min
- Rest days: declared ahead, 2 per ISO week, HMAC-signed; pay the first workout unit
- Grace floor: shutdown >= first task + 60 min (capped); skipped on a sick day
- Workout ledger: one signed credit row per log credit slot and rest day
- Day-aware ladder amounts from earned_time 0.4.0; preview_shutdown dry-run script

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:43:45 +02:00

243 lines
8.6 KiB
Python

"""Tests for ``_earned``: where screen-locker reads each earner's ledger, and how.
Which rows count -- forged rows, yesterday's solve, an unparsable stamp -- is
``earned_time``'s rule and is tested there. Pinned here is screen-locker's
side of it: the ledger lives under ``LEDGER_HOME``, the key is
``HMAC_KEY_FILE``, an unreadable one fails closed to ``None`` and is logged,
and the schedule's times are shown exactly (``hhmm`` / ``span``).
"""
from __future__ import annotations
from datetime import datetime, timedelta
import json
import logging
from typing import TYPE_CHECKING
import earned_time
import pytest
from screen_locker import _constants, _earned
from screen_locker._earned import (
earned_today,
flat_answers,
flat_earners,
hhmm,
ledger_file,
span,
)
from screen_locker.tests._earned_fixtures import (
EXTRA,
credit,
signing_key,
write_ledger,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_NOW = datetime(2026, 10, 2, 14, 0).astimezone()
_LEDGERED = pytest.mark.parametrize(
"earner", [earned_time.LEETCODE, earned_time.READING], ids=lambda e: e.name
)
@pytest.fixture
def key(tmp_path: Path) -> Iterator[Path]:
with signing_key(tmp_path) as path:
yield path
class TestHhmm:
def test_whole_hours_are_zero_padded(self) -> None:
assert hhmm(0) == "00:00"
assert hhmm(9 * 60) == "09:00"
assert hhmm(21 * 60) == "21:00"
def test_minutes_are_kept_exactly(self) -> None:
"""A 30-minute earner shows as :30, never floored to the hour."""
assert hhmm(18 * 60 + 30) == "18:30"
assert hhmm(5) == "00:05"
def test_midnight_cap_is_24_00(self) -> None:
assert hhmm(24 * 60) == "24:00"
class TestSpan:
def test_whole_hours(self) -> None:
assert span(0) == "0h"
assert span(60) == "1h"
assert span(120) == "2h"
def test_minutes_only(self) -> None:
assert span(30) == "30m"
def test_hours_and_minutes_combine(self) -> None:
assert span(90) == "1h30m"
assert span(125) == "2h05m"
class TestLedgerFile:
@_LEDGERED
def test_resolves_under_ledger_home(self, earner: earned_time.Earner) -> None:
assert earner.ledger is not None
assert ledger_file(earner) == _earned.LEDGER_HOME / earner.ledger
def test_ledger_home_and_key_are_redirected_for_the_suite(
self, tmp_path: Path
) -> None:
assert tmp_path / "ledger_home" == _earned.LEDGER_HOME
assert tmp_path / "earned_hmac.key" == _earned.HMAC_KEY_FILE
assert not _earned.HMAC_KEY_FILE.exists()
def test_suite_never_sees_a_real_ledger(self) -> None:
assert not any(ledger_file(e).exists() for e in flat_earners())
def test_earner_without_a_ledger_raises(self) -> None:
bare = earned_time.Earner(
name="bare", label="Bare", gaming_minutes=0, shutdown_minutes=0
)
with pytest.raises(ValueError, match="has no ledger"):
ledger_file(bare)
class TestFlatEarners:
def test_registry_order_without_the_counted_workout(self) -> None:
flat = tuple(e for e in earned_time.EARNERS if e.kind == "flat")
assert flat_earners() == flat
assert flat[:3] == (earned_time.LEETCODE, earned_time.READING, earned_time.ANKI)
def test_a_newly_registered_earner_is_picked_up(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
assert flat_earners()[-1] is EXTRA
def test_a_flat_earner_without_a_ledger_is_left_out(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No ledger means no shared reader: its answer is the consumer's."""
unledgered = earned_time.Earner(
name="manual", label="manual", gaming_minutes=60, shutdown_minutes=60
)
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, unledgered))
assert unledgered not in flat_earners()
def test_conftest_seeds_every_flat_earners_stamp(self) -> None:
"""So the live pass never reads a ledger (or the real key) by default."""
seeded = json.loads(_constants.SHUTDOWN_BASE_FILE.read_text())
for earner in flat_earners():
assert f"{earner.name}_bonus_date" in seeded
class TestEarnedToday:
@_LEDGERED
def test_signed_credit_today_earns(
self, key: Path, earner: earned_time.Earner
) -> None:
write_ledger(earner, [credit(earner)])
assert earned_today(earner) is True
@_LEDGERED
def test_now_is_passed_through(self, key: Path, earner: earned_time.Earner) -> None:
write_ledger(earner, [credit(earner, _NOW - timedelta(hours=1))])
assert earned_today(earner, now=_NOW) is True
assert earned_today(earner, now=_NOW - timedelta(hours=2)) is False
assert earned_today(earner, now=_NOW + timedelta(days=1)) is False
@_LEDGERED
def test_rows_are_verified_against_hmac_key_file(
self, tmp_path: Path, earner: earned_time.Earner
) -> None:
"""Signed with another key than the one on disk: a forgery, no time."""
write_ledger(earner, [credit(earner)])
with signing_key(tmp_path, b"another-key"):
assert earned_today(earner) is False
@_LEDGERED
def test_a_ledger_outside_ledger_home_is_never_read(
self, tmp_path: Path, key: Path, earner: earned_time.Earner
) -> None:
stray = tmp_path / "ledger.json"
stray.write_text(json.dumps({"entries": [credit(earner)]}))
assert earned_today(earner) is not True
class TestCannotCheck:
"""Every unreadable state is None -- never a confident False -- and logged."""
@_LEDGERED
def test_missing_key(
self,
tmp_path: Path,
earner: earned_time.Earner,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The key is checked first: without it even "no ledger" is unknown."""
monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key")
with caplog.at_level(logging.WARNING):
assert earned_today(earner) is None
assert "Cannot read the integrity key" in caplog.text
def test_empty_key(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)])
with signing_key(tmp_path, b" \n"), caplog.at_level(logging.WARNING):
assert earned_today(earned_time.LEETCODE) is None
assert "is empty" in caplog.text
def test_missing_leetcode_ledger_is_unknown(
self, key: Path, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
assert earned_today(earned_time.LEETCODE) is None
assert "Cannot read the LeetCode ledger" in caplog.text
def test_missing_reading_ledger_is_a_plain_no(self, key: Path) -> None:
"""book-guard never ran: no reading, but no fault either."""
assert earned_today(earned_time.READING) is False
@_LEDGERED
def test_corrupt_ledger(
self,
key: Path,
earner: earned_time.Earner,
caplog: pytest.LogCaptureFixture,
) -> None:
path = ledger_file(earner)
path.parent.mkdir(parents=True)
path.write_text("{not json")
with caplog.at_level(logging.WARNING):
assert earned_today(earner) is None
assert "not valid JSON" in caplog.text
class TestFlatAnswers:
def test_every_flat_earner_answers_by_name(self, key: Path) -> None:
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)])
nos = dict.fromkeys((e.name for e in flat_earners()), False)
assert flat_answers() == {**nos, "leetcode": True}
def test_an_unknown_answer_is_none_and_warned(
self,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key")
with caplog.at_level(logging.WARNING):
assert flat_answers() == dict.fromkeys(e.name for e in flat_earners())
for earner in flat_earners():
assert f"{earner.label} state could not be checked" in caplog.text
def test_a_no_is_not_warned(
self, key: Path, caplog: pytest.LogCaptureFixture
) -> None:
write_ledger(earned_time.LEETCODE, [])
with caplog.at_level(logging.WARNING):
assert flat_answers() == dict.fromkeys(
(e.name for e in flat_earners()), False
)
assert "could not be checked" not in caplog.text