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

157 lines
6.2 KiB
Python

"""The RunnerUp backfill credits each fill through the shared ``on_ingested``.
Regression for 2026-09-16: a run synced off the phone at 20:25 was appended
to the log but earned only the weekly-surplus bonus (+1h per workout above
the weekly minimum), which was 0 that early in the week -- the shutdown hour
stayed at 20:00 while the daily base reset would have derived 22:00 from the
very same log. Every fill now goes through the same credit callback as a
synced manual workout or StrongLifts session.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._log_io import load_workout_log
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
_RUN = {"sport": 0, "duration_seconds": 4200, "distance_m": 5800}
def _stub_phone(locker: object, dates: set[str]) -> None:
"""Make the scan see one verified run on each of ``dates``."""
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(side_effect=lambda d: ["/sdcard/run.tcx"] if d in dates else []),
)
object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=_RUN))
object.__setattr__(
locker, "_validate_runnerup_data", MagicMock(return_value=("verified", "ok"))
)
class TestBackfillCreditCallback:
"""``_try_fill_runnerup_for_date`` / ``_scan_and_fill_week_runnerup``."""
def test_callback_gets_workout_data_and_prior_entries(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The callback sees the appended entry and that day's prior entries."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text(
'{"2026-09-16": [{"workout_data": {"type": "manual_workout"}}]}'
)
_stub_phone(locker, {"2026-09-16"})
seen: list[tuple[dict, list[dict]]] = []
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"):
appended = locker._try_fill_runnerup_for_date(
"2026-09-16", log_file, lambda e, p: seen.append((e, p))
)
assert appended is True
assert len(seen) == 1
entry, prior = seen[0]
assert entry["type"] == "runnerup_verified"
assert entry["distance_km"] == 5.8
assert [e["workout_data"]["type"] for e in prior] == ["manual_workout"]
def test_duplicate_fill_does_not_fire_callback(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A re-scan of an already-logged run appends nothing and credits nothing."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("{}")
_stub_phone(locker, {"2026-09-16"})
callback = MagicMock()
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"):
first = locker._try_fill_runnerup_for_date("2026-09-16", log_file, callback)
second = locker._try_fill_runnerup_for_date(
"2026-09-16", log_file, callback
)
assert (first, second) == (True, False)
assert callback.call_count == 1
assert len(load_workout_log(log_file)["2026-09-16"]) == 1
def test_no_callback_still_appends(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Callers that only want the log filled (no reward) keep working."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("{}")
_stub_phone(locker, {"2026-09-16"})
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"):
assert locker._try_fill_runnerup_for_date("2026-09-16", log_file) is True
def test_week_scan_passes_callback_to_every_fill(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The week scan forwards ``on_ingested`` to each day it fills."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("{}")
fill = MagicMock(return_value=True)
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(locker, "_try_fill_runnerup_for_date", fill)
callback = MagicMock()
filled = locker._scan_and_fill_week_runnerup(log_file, on_ingested=callback)
assert filled == fill.call_count >= 1
assert all(call.args[2] is callback for call in fill.call_args_list)
@pytest.mark.usefixtures("pre_ladder")
class TestSyncPassCreditsRunnerUp:
"""``_auto_fill_week_runnerup_bonus`` (the 15-minute timer path)."""
def test_first_run_of_the_day_pushes_shutdown_two_hours(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Base 20:00 + today's first counted workout = 22:00 -- the day rule."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file.write_text("{}")
_stub_phone(locker, {"2026-09-16"})
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
lambda log, on_ingested=None: locker._try_fill_runnerup_for_date(
"2026-09-16", log, on_ingested
),
)
config = {"hours": (1200, 1200, 300)}
def write(mw: int, ts: int, me: int, restore: bool = False) -> bool:
config["hours"] = (mw, ts, me)
return True
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=lambda: config["hours"]),
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(side_effect=write)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"):
locker._auto_fill_week_runnerup_bonus()
locker._auto_fill_week_runnerup_bonus()
assert config["hours"] == (1320, 1320, 300)
assert locker.workout_data == {}