screen-locker/screen_locker/tests/test_shutdown_base_replay.py
Krzysztof kuhy Rudnicki 82f3462896 Replay today's earned shutdown credit in the daily base reset
The i3bar showed 21:00 on 2026-09-13 with a counted workout on disk. The
football was logged at 00:02 and credited against the previous day's
config (already at the 23 ceiling, so it bought nothing); the 09:15 base
reset then wrote 21/21 and only re-layered the weekly bank, never asking
what today's log had already earned. Live credits are read-add-write, so
every workout credited between local midnight and the first locker tick
of the day lost its hours to the reset that followed.

- _shutdown_base.reset_to_base_if_new_day takes the log and writes
  min(23, base + hours today's entries earned) in one write, using the
  same credit rule as the weekly count. Idempotent: still once per day.
- _workout_credit names FIRST_WORKOUT_BONUS_HOURS=2 /
  EXTRA_WORKOUT_BONUS_HOURS=1 and derives earned_shutdown_bonus_hours()
  from them; _shutdown and the +1h path use the constants instead of
  literals so the replay and the live paths cannot drift.
- _weekly_check.credit_key: a synced copy whose sync_record_id names an
  entry already filed on the same day shares that entry's slot. This
  morning's rekey (967e3cd) let the sync pull the football back in as
  manual:2026-09-13T14:30, which counted as a second credit (weekly 4/5
  instead of 3/5) and would have made the replay ask for 24.
- annotate_duplicate_credits.py passes the day's id index so the ledger
  records the manual duplicate the same way it records phone/PC pairs.

Verified through the real path: cleared last_reset_date, started
workout-locker.service -> "--restore 23 23 5", "Daily base reset:
Mon-Wed=23, Thu-Sun=23 (base 21/21 + 2h already earned today)",
shutdown_countdown.sh prints "23:00"; a second start changed nothing.
leetcode-guard grants no shutdown time anywhere (grepped screen-locker,
digital-wellbeing, i3 config) — the 23:00 comes from the workout alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
2026-09-13 20:00:05 +02:00

207 lines
8.0 KiB
Python

"""Tests for the daily reset replaying today's already-earned shutdown credit.
Guards the 2026-09-13 failure: a manual workout logged at 00:02 earned its
hours against the previous day's config, the 09:15 base reset wiped them, and
the bar sat at 21:00 with a counted workout on disk. The reset must now write
``base + what today's log has earned``, counted by the same credit rule as the
weekly total so a synced duplicate of one workout earns once.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
from screen_locker._day import today_str
from screen_locker._shutdown_base import (
reset_to_base_if_new_day,
today_earned_bonus_hours,
)
from screen_locker._weekly_check import (
count_day_credits,
credit_key,
day_workout_index,
)
from screen_locker._workout_credit import earned_shutdown_bonus_hours
if TYPE_CHECKING:
from pathlib import Path
_DAY = "2026-09-13"
def _manual(workout_id: str, *, sync_record_id: str | None = None) -> dict[str, Any]:
"""Build a manual_workout entry, optionally stamped as a synced copy."""
data: dict[str, Any] = {"type": "manual_workout", "source": "football 11v11"}
if sync_record_id is not None:
data["sync_record_id"] = sync_record_id
return {
"timestamp": f"{_DAY}T07:40:06+00:00",
"workout_data": data,
"workout_id": workout_id,
}
def _real_2026_09_13() -> list[dict[str, Any]]:
"""The two entries log.json held for 2026-09-13: one football, twice."""
return [
_manual("manual:2026-09-12T14:30"),
_manual("manual:2026-09-13T14:30", sync_record_id="manual:2026-09-12T14:30"),
]
def _write_log(log_file: Path, day: str, entries: list[dict[str, Any]]) -> Path:
log_file.write_text(json.dumps({day: entries}))
return log_file
class TestEarnedShutdownBonusHours:
"""First credit is worth 2h, every further one 1h."""
def test_no_credits_earn_nothing(self) -> None:
assert earned_shutdown_bonus_hours(0) == 0
def test_negative_is_treated_as_nothing(self) -> None:
assert earned_shutdown_bonus_hours(-1) == 0
def test_first_credit_is_two_hours(self) -> None:
assert earned_shutdown_bonus_hours(1) == 2
def test_further_credits_add_one_hour_each(self) -> None:
assert earned_shutdown_bonus_hours(3) == 4
class TestSyncedCopySharesCredit:
"""A sync re-ingesting an entry already on the day is the same workout."""
def test_real_day_counts_one_credit(self) -> None:
assert count_day_credits(_DAY, _real_2026_09_13()) == 1
def test_copy_and_original_share_a_key(self) -> None:
entries = _real_2026_09_13()
index = day_workout_index(entries)
assert credit_key(_DAY, 0, entries[0], index) == credit_key(
_DAY, 1, entries[1], index
)
def test_sync_id_naming_an_absent_entry_stays_separate(self) -> None:
"""A synced manual whose source is NOT on this day is its own workout."""
entries = [
_manual("manual:2026-09-13T08:00"),
_manual(
"manual:2026-09-13T18:00", sync_record_id="manual:2026-09-13T18:00"
),
]
assert count_day_credits(_DAY, entries) == 2
def test_two_genuine_manual_workouts_still_both_count(self) -> None:
entries = [
_manual("manual:2026-09-13T08:00"),
_manual("manual:2026-09-13T18:00"),
]
assert count_day_credits(_DAY, entries) == 2
def test_without_sibling_index_keys_by_position(self) -> None:
copy = _real_2026_09_13()[1]
assert credit_key(_DAY, 3, copy) == ("manual_workout", f"{_DAY}#3")
def test_day_workout_index_skips_entries_without_an_id(self) -> None:
entries = [{"workout_data": {"type": "x"}}, _manual("manual:2026-09-13T08:00")]
assert day_workout_index(entries) == {"manual:2026-09-13T08:00": 1}
def test_day_workout_index_keeps_the_first_position_of_a_repeated_id(self) -> None:
entries = [
_manual("manual:2026-09-13T08:00"),
_manual("manual:2026-09-13T08:00"),
]
assert day_workout_index(entries) == {"manual:2026-09-13T08:00": 0}
def test_non_string_sync_id_is_ignored(self) -> None:
entry = _manual("manual:2026-09-13T08:00")
entry["workout_data"]["sync_record_id"] = 42
assert credit_key(_DAY, 0, entry, {"42": 5}) == ("manual_workout", f"{_DAY}#0")
class TestTodayEarnedBonusHours:
"""The log → hours derivation the reset relies on."""
def test_real_day_earns_two_hours(self, tmp_path: Path) -> None:
log = _write_log(tmp_path / "log.json", _DAY, _real_2026_09_13())
assert today_earned_bonus_hours(log, _DAY) == 2
def test_day_absent_from_log_earns_nothing(self, tmp_path: Path) -> None:
log = _write_log(tmp_path / "log.json", "2026-09-12", _real_2026_09_13())
assert today_earned_bonus_hours(log, _DAY) == 0
def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None:
log = tmp_path / "log.json"
log.write_text(
json.dumps({_DAY: ["garbage", _manual("manual:2026-09-13T08:00")]})
)
assert today_earned_bonus_hours(log, _DAY) == 2
class TestResetReplaysTodaysCredit:
"""reset_to_base_if_new_day writes base + earned, capped at the ceiling."""
def _mixin(self) -> MagicMock:
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (21, 21, 5)
mixin._write_shutdown_config.return_value = True
return mixin
def _state(self, tmp_path: Path, base: int = 21) -> Path:
state = tmp_path / "state.json"
state.write_text(
json.dumps(
{
"last_reset_date": "2000-01-01",
"base_mon_wed_hour": base,
"base_thu_sun_hour": base,
}
)
)
return state
def test_writes_base_plus_todays_earned_hours(self, tmp_path: Path) -> None:
"""The 2026-09-13 case: base 21 + one football → 23, not 21."""
log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13())
mixin = self._mixin()
assert (
reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True
)
mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True)
def test_without_log_file_writes_plain_base(self, tmp_path: Path) -> None:
mixin = self._mixin()
assert reset_to_base_if_new_day(self._state(tmp_path), mixin) is True
mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True)
def test_empty_day_writes_plain_base(self, tmp_path: Path) -> None:
log = _write_log(tmp_path / "log.json", "2000-01-01", _real_2026_09_13())
mixin = self._mixin()
assert (
reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True
)
mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True)
def test_earned_hours_are_capped_at_the_restore_ceiling(
self, tmp_path: Path
) -> None:
"""base 22 + 2h earned would be 24; the script clamps at 23, so ask for 23."""
log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13())
mixin = self._mixin()
state = self._state(tmp_path, base=22)
assert reset_to_base_if_new_day(state, mixin, log_file=log) is True
mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True)
def test_state_file_still_records_base_not_target(self, tmp_path: Path) -> None:
"""Tomorrow's reset must start from 21 again, not from today's 23."""
log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13())
state = self._state(tmp_path)
reset_to_base_if_new_day(state, self._mixin(), log_file=log)
saved = json.loads(state.read_text())
assert (saved["base_mon_wed_hour"], saved["base_thu_sun_hour"]) == (21, 21)
assert saved["last_reset_date"] == today_str()