mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
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
157 lines
6.2 KiB
Python
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 == {}
|