screen-locker/screen_locker/tests/test_workout_ledger_cli.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

96 lines
3.3 KiB
Python

"""Tests for ``--backfill-workout-ledger``: idempotent, per source, range-aware."""
from __future__ import annotations
from datetime import date, datetime
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
from screen_locker._rest_day import declare
from screen_locker._workout_ledger import ledger_path
from screen_locker._workout_ledger_cli import backfill_rows, run_backfill
from screen_locker.tests._earned_fixtures import signing_key
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_DAY = date(2026, 10, 4)
_ISO = _DAY.isoformat()
@pytest.fixture
def key(tmp_path: Path) -> Iterator[Path]:
with signing_key(tmp_path) as path:
yield path
def entry(data: dict[str, Any]) -> dict[str, Any]:
return {"timestamp": "2026-10-04T20:00:00+00:00", "workout_data": data}
def ledger() -> list[dict[str, Any]]:
return json.loads(ledger_path().read_text())["entries"]
@pytest.mark.usefixtures("key")
class TestBackfill:
def _log(self, tmp_path: Path) -> Path:
log = tmp_path / "log.json"
log.write_text(
json.dumps(
{
_ISO: [
entry({"type": "phone_verified"}),
entry({"type": "relaxed_day_skip"}),
],
"2026-10-05": [
entry({"type": "runnerup_verified", "completed_at": 9.0})
],
}
)
)
return log
def test_rows_for_a_range_and_its_rest_days(self, tmp_path: Path) -> None:
with patch("screen_locker._rest_day.datetime") as clock:
clock.now.return_value = datetime(2026, 10, 1).astimezone()
assert declare(date(2026, 10, 6)).ok
ledger_path().unlink() # the declaration mirrored it; backfill must too
rows = backfill_rows(self._log(tmp_path), _DAY, date(2026, 10, 6))
assert [r["detail"]["source"] for r in rows] == [
"stronglifts",
"runnerup_tcx",
"rest_day",
]
def test_dry_run_prints_and_writes_nothing(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
argv = ["--dry-run", "--from", _ISO, "--to", "2026-10-05"]
assert run_backfill(self._log(tmp_path), argv) == 0
out = capsys.readouterr().out
assert "(missing)" in out
assert "per source: {'runnerup_tcx': 1, 'stronglifts': 1}" in out
assert not ledger_path().exists()
def test_a_real_run_is_idempotent(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
log = self._log(tmp_path)
assert run_backfill(log, ["--from", _ISO, "--to", "2026-10-05"]) == 0
assert run_backfill(log, ["--from", _ISO, "--to", "2026-10-05"]) == 0
out = capsys.readouterr().out.splitlines()
assert out[0].startswith("2 new row(s)")
assert out[1].startswith("0 new row(s)")
assert run_backfill(log, ["--dry-run"]) == 0
assert "(present)" in capsys.readouterr().out
def test_default_is_today(self, tmp_path: Path) -> None:
with patch("screen_locker._workout_ledger_cli.today_str", return_value=_ISO):
assert run_backfill(self._log(tmp_path), []) == 0
assert len(ledger()) == 1