mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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
This commit is contained in:
parent
f79b3c69cf
commit
82f3462896
@ -13,6 +13,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_sick_state import SickDayStateMixin
|
||||
from screen_locker._wake_alarm import WakeAlarmMixin
|
||||
from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_HOURS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -97,8 +98,8 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
||||
if config_values is None:
|
||||
return False
|
||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
||||
new_mon_wed = min(23, mon_wed_hour + 2)
|
||||
new_thu_sun = min(23, thu_sun_hour + 2)
|
||||
new_mon_wed = min(23, mon_wed_hour + FIRST_WORKOUT_BONUS_HOURS)
|
||||
new_thu_sun = min(23, thu_sun_hour + FIRST_WORKOUT_BONUS_HOURS)
|
||||
return self._write_shutdown_config(
|
||||
new_mon_wed,
|
||||
new_thu_sun,
|
||||
|
||||
@ -4,6 +4,12 @@ On each new calendar day the shutdown config is reset to base hours (21:00
|
||||
by default) so that the day's workout bonuses always layer on top of a known
|
||||
floor rather than accumulating indefinitely across days.
|
||||
|
||||
The reset re-derives what today's log has ALREADY earned and writes base plus
|
||||
that in one go. Live credits are read-add-write against the config, so a
|
||||
workout credited between local midnight and the first locker tick of the day
|
||||
(a manual log at 00:02, say) had its hours wiped by the reset that followed —
|
||||
on 2026-09-13 that left the bar at 21:00 with a counted workout on disk.
|
||||
|
||||
The sick-day state file is cleared on reset so the sick-restore path cannot
|
||||
overwrite the fresh base when it runs later in the same startup.
|
||||
"""
|
||||
@ -15,6 +21,9 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._weekly_check import count_day_credits
|
||||
from screen_locker._workout_credit import earned_shutdown_bonus_hours
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -22,6 +31,21 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BASE_HOUR = 21
|
||||
# Mirrors RESTORE_CEILING in adjust_shutdown_schedule.sh: the script clamps any
|
||||
# restore above it, so the target is computed against the same ceiling here
|
||||
# rather than asking for hours the write will silently cut.
|
||||
_RESTORE_CEILING_HOUR = 23
|
||||
|
||||
|
||||
def today_earned_bonus_hours(log_file: Path, today: str) -> int:
|
||||
"""Return the shutdown hours today's logged workouts have already earned.
|
||||
|
||||
Counted with the same :func:`~screen_locker._weekly_check.count_day_credits`
|
||||
rule as the weekly total, so a workout recorded twice earns once.
|
||||
"""
|
||||
entries = load_workout_log(log_file).get(today, [])
|
||||
credit_count = count_day_credits(today, [e for e in entries if isinstance(e, dict)])
|
||||
return earned_shutdown_bonus_hours(credit_count)
|
||||
|
||||
|
||||
def get_base_hours(state_file: Path) -> tuple[int, int]:
|
||||
@ -53,13 +77,16 @@ def reset_to_base_if_new_day(
|
||||
state_file: Path,
|
||||
mixin: object,
|
||||
sick_day_state_file: Path | None = None,
|
||||
log_file: Path | None = None,
|
||||
) -> bool:
|
||||
"""Reset the shutdown config to base hours if a new calendar day has begun.
|
||||
|
||||
Writes base hours via *mixin*._write_shutdown_config (with restore=True so
|
||||
the script allows moving the time earlier), updates ``last_reset_date`` in
|
||||
*state_file*, and removes *sick_day_state_file* if it exists so the
|
||||
sick-restore path does not fight with the fresh base on the same startup.
|
||||
Writes base hours plus whatever *log_file* shows today has already earned
|
||||
(see :func:`today_earned_bonus_hours`) via *mixin*._write_shutdown_config
|
||||
(with restore=True so the script allows moving the time earlier), updates
|
||||
``last_reset_date`` in *state_file*, and removes *sick_day_state_file* if
|
||||
it exists so the sick-restore path does not fight with the fresh base on
|
||||
the same startup.
|
||||
|
||||
Returns True if a reset was performed, False if today was already reset.
|
||||
"""
|
||||
@ -80,12 +107,17 @@ def reset_to_base_if_new_day(
|
||||
)
|
||||
|
||||
base_mw, base_ts = get_base_hours(state_file)
|
||||
earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0
|
||||
target_mw = min(_RESTORE_CEILING_HOUR, base_mw + earned)
|
||||
target_ts = min(_RESTORE_CEILING_HOUR, base_ts + earned)
|
||||
|
||||
# Preserve the morning-end hour from the live config.
|
||||
config = mixin._read_shutdown_config()
|
||||
morning_end = config[2] if config else 5
|
||||
|
||||
ok: bool = mixin._write_shutdown_config(base_mw, base_ts, morning_end, restore=True)
|
||||
ok: bool = mixin._write_shutdown_config(
|
||||
target_mw, target_ts, morning_end, restore=True
|
||||
)
|
||||
if not ok:
|
||||
_logger.warning("Daily base reset: failed to write shutdown config.")
|
||||
return False
|
||||
@ -111,5 +143,13 @@ def reset_to_base_if_new_day(
|
||||
except OSError as exc:
|
||||
_logger.warning("Daily base reset: failed to write state file: %s", exc)
|
||||
|
||||
_logger.info("Daily base reset: Mon-Wed=%d, Thu-Sun=%d.", base_mw, base_ts)
|
||||
_logger.info(
|
||||
"Daily base reset: Mon-Wed=%d, Thu-Sun=%d (base %d/%d + %dh already "
|
||||
"earned today).",
|
||||
target_mw,
|
||||
target_ts,
|
||||
base_mw,
|
||||
base_ts,
|
||||
earned,
|
||||
)
|
||||
return True
|
||||
|
||||
@ -108,7 +108,10 @@ class StartupChecksMixin(SyncMixin):
|
||||
# Reset shutdown config to base (21:00) at the start of each new day,
|
||||
# then layer this week's earned bonus back on top of the fresh base.
|
||||
if reset_to_base_if_new_day(
|
||||
SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE
|
||||
SHUTDOWN_BASE_FILE,
|
||||
self,
|
||||
sick_day_state_file=SICK_DAY_STATE_FILE,
|
||||
log_file=self.log_file,
|
||||
):
|
||||
self._apply_weekly_shutdown_bonus()
|
||||
# Ingest any manual workouts synced from the phone (or another device)
|
||||
|
||||
@ -91,6 +91,7 @@ def credit_key(
|
||||
date_str: str,
|
||||
entry_index: int,
|
||||
entry: Mapping[str, Any],
|
||||
sibling_index: Mapping[str, int] | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Return the credit slot a log entry occupies, or None if it earns none.
|
||||
|
||||
@ -104,6 +105,10 @@ def credit_key(
|
||||
entry_index: Position of the entry within that date's list, used to
|
||||
keep independently-earned credits distinct.
|
||||
entry: The log entry.
|
||||
sibling_index: ``workout_id`` → position for every entry filed under
|
||||
the same day (see :func:`day_workout_index`). A synced copy names
|
||||
the entry it was pulled from in ``sync_record_id``; when that entry
|
||||
is already here, the copy is the same workout and shares its slot.
|
||||
|
||||
Returns:
|
||||
A hashable key shared by entries that represent the same workout, or
|
||||
@ -120,9 +125,33 @@ def credit_key(
|
||||
# Verified runs and manual workouts are genuinely separate sessions, so
|
||||
# several on one day all count -- the manual-workout rate budget (see
|
||||
# screen_locker._manual_workout) is the anti-gaming limiter, not a collapse.
|
||||
# The one exception is a sync re-ingesting an entry that is already on
|
||||
# this day under a different workout_id (2026-09-13: a manual workout
|
||||
# rekeyed to its local day, then pulled back in stamped with that day).
|
||||
sync_id = (
|
||||
workout_data.get("sync_record_id")
|
||||
if isinstance(workout_data, Mapping)
|
||||
else None
|
||||
)
|
||||
if sibling_index and isinstance(sync_id, str) and sync_id in sibling_index:
|
||||
entry_index = sibling_index[sync_id]
|
||||
return (wtype, f"{date_str}#{entry_index}")
|
||||
|
||||
|
||||
def day_workout_index(entries: Iterable[Mapping[str, Any]]) -> dict[str, int]:
|
||||
"""Map each ``workout_id`` on one day to the position of its first entry.
|
||||
|
||||
Built once per day and handed to :func:`credit_key`, so a synced copy can
|
||||
resolve its ``sync_record_id`` to the slot of the entry it duplicates.
|
||||
"""
|
||||
index: dict[str, int] = {}
|
||||
for position, entry in enumerate(entries):
|
||||
wid = entry.get("workout_id")
|
||||
if wid is not None:
|
||||
index.setdefault(str(wid), position)
|
||||
return index
|
||||
|
||||
|
||||
def count_day_credits(date_str: str, entries: Iterable[Mapping[str, Any]]) -> int:
|
||||
"""Count the distinct workout credits earned on one day.
|
||||
|
||||
@ -132,12 +161,15 @@ def count_day_credits(date_str: str, entries: Iterable[Mapping[str, Any]]) -> in
|
||||
|
||||
Returns:
|
||||
The number of distinct credits, after collapsing the two StrongLifts
|
||||
ingestion paths onto one slot.
|
||||
ingestion paths (and any synced copy of an entry already present)
|
||||
onto one slot.
|
||||
"""
|
||||
day_entries = list(entries)
|
||||
sibling_index = day_workout_index(day_entries)
|
||||
keys = {
|
||||
key
|
||||
for index, entry in enumerate(entries)
|
||||
if (key := credit_key(date_str, index, entry)) is not None
|
||||
for index, entry in enumerate(day_entries)
|
||||
if (key := credit_key(date_str, index, entry, sibling_index)) is not None
|
||||
}
|
||||
return len(keys)
|
||||
|
||||
|
||||
@ -25,6 +25,22 @@ if TYPE_CHECKING:
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The shutdown reward for a day's workouts: the first counted workout pushes
|
||||
# shutdown later by FIRST_WORKOUT_BONUS_HOURS, every further one by
|
||||
# EXTRA_WORKOUT_BONUS_HOURS. Both the live credit paths below and the daily
|
||||
# base reset (``_shutdown_base``) derive from these two numbers, so a day's
|
||||
# earned hours can be recomputed from the log instead of being lost when the
|
||||
# credit landed before the reset ran.
|
||||
FIRST_WORKOUT_BONUS_HOURS = 2
|
||||
EXTRA_WORKOUT_BONUS_HOURS = 1
|
||||
|
||||
|
||||
def earned_shutdown_bonus_hours(credit_count: int) -> int:
|
||||
"""Return the shutdown hours a day with *credit_count* distinct workouts earned."""
|
||||
if credit_count <= 0:
|
||||
return 0
|
||||
return FIRST_WORKOUT_BONUS_HOURS + (credit_count - 1) * EXTRA_WORKOUT_BONUS_HOURS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkoutCreditResult:
|
||||
@ -112,7 +128,7 @@ class WorkoutCreditMixin:
|
||||
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
|
||||
elif self.workout_data.get("type") in COUNTED_WORKOUT_TYPES:
|
||||
old_cfg = self._read_shutdown_config()
|
||||
if old_cfg and self._adjust_shutdown_time_by(1):
|
||||
if old_cfg and self._adjust_shutdown_time_by(EXTRA_WORKOUT_BONUS_HOURS):
|
||||
new_cfg = self._read_shutdown_config()
|
||||
if new_cfg:
|
||||
extra_bonus_delta = new_cfg[1] - old_cfg[1]
|
||||
|
||||
206
screen_locker/tests/test_shutdown_base_replay.py
Normal file
206
screen_locker/tests/test_shutdown_base_replay.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""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()
|
||||
@ -38,7 +38,7 @@ import sys
|
||||
from typing import TypedDict
|
||||
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._weekly_check import count_day_credits, credit_key
|
||||
from screen_locker._weekly_check import count_day_credits, credit_key, day_workout_index
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -98,7 +98,7 @@ def _shared_credits(date_str: str, entries: list[dict]) -> list[CreditRecord]:
|
||||
"""
|
||||
groups: dict[tuple[str, str], list[tuple[int, dict]]] = defaultdict(list)
|
||||
for index, entry in enumerate(entries):
|
||||
key = credit_key(date_str, index, entry)
|
||||
key = credit_key(date_str, index, entry, day_workout_index(entries))
|
||||
if key is not None:
|
||||
groups[key].append((index, entry))
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user