mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
Give manual workouts full effect parity; raise minimum to 5, double budget
Synced manual workouts never earned shutdown/debt credit (only weekly count), which silently missed pushing shutdown time for a same-day workout logged on the phone. Manual workouts now go through the same credit logic as any other workout type, count individually toward the weekly total and rate budget (no more once-per-day collapse), and stack same-day shutdown bonuses like verified workouts. Also bumps the weekly minimum from 4 to 5 and doubles the monthly manual-workout budget to 10. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
57c3f30e7b
commit
298daf028d
@ -35,7 +35,7 @@ COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
||||
# activities like table tennis recur more often than illness. Once either
|
||||
# window is exhausted the "Log Manual Workout" option disappears entirely.
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 5
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
|
||||
# Minimum start-to-end duration for a manual entry to be accepted.
|
||||
MANUAL_WORKOUT_MIN_DURATION_MINUTES = 20
|
||||
# Minimum chars for the "what was done" activity-details field.
|
||||
|
||||
@ -4,7 +4,9 @@ A manual workout logged on another device (phone form, or the PC form pushed
|
||||
from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload
|
||||
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
|
||||
turns those synced payloads into ordinary signed ``manual_workout`` entries in
|
||||
``workout_log.json`` so they count toward the weekly minimum.
|
||||
``workout_log.json`` so they count toward the weekly minimum and — via the
|
||||
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
|
||||
live-logged workout would.
|
||||
|
||||
Trust model: the PC never trusts the phone's derived fields. Every synced
|
||||
manual is re-validated with the same :func:`validate_manual_workout` the local
|
||||
@ -31,9 +33,11 @@ from screen_locker._manual_workout import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Key under which the source sync record id is stored in the ingested
|
||||
@ -119,6 +123,7 @@ def ingest_manual_records(
|
||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||
*,
|
||||
today: str | None = None,
|
||||
on_ingested: OnIngestedCallback | None = None,
|
||||
) -> list[str]:
|
||||
"""Ingest synced manual workouts into ``workout_log.json``.
|
||||
|
||||
@ -126,8 +131,17 @@ def ingest_manual_records(
|
||||
reconstruct + re-validate the draft on the PC, enforce the rate budget, then
|
||||
HMAC-sign and APPEND it under its own ``date`` (a day may hold several
|
||||
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
|
||||
the write chokepoint, so re-syncing the same record is a no-op. A back-dated
|
||||
ingest raises the weekly count but never pushes tonight's shutdown.
|
||||
the write chokepoint, so re-syncing the same record is a no-op.
|
||||
|
||||
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
|
||||
right after each newly-appended record — ``prior_entries`` being that
|
||||
entry's own date's entries written before it — so the caller (see
|
||||
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
|
||||
identical live-workout shutdown/debt reward. This applies regardless of
|
||||
whether ``date`` is today or back-dated: there's only one current shutdown
|
||||
config, so a back-dated sync still pushes it exactly as a live workout
|
||||
would have that day.
|
||||
|
||||
Returns the record ids actually ingested.
|
||||
"""
|
||||
ingested: list[str] = []
|
||||
@ -153,6 +167,15 @@ def ingest_manual_records(
|
||||
continue
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
write_signed_entry(log_file, date, entry)
|
||||
result = write_signed_entry(log_file, date, entry)
|
||||
if not result.appended:
|
||||
_logger.warning(
|
||||
"Manual record %s collided with an existing workout_id — "
|
||||
"skipped without credit",
|
||||
record_id,
|
||||
)
|
||||
continue
|
||||
ingested.append(record_id)
|
||||
if on_ingested is not None:
|
||||
on_ingested(entry, result.prior_entries)
|
||||
return ingested
|
||||
|
||||
@ -72,11 +72,13 @@ def count_in_window(
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Return how many DAYS in the trailing window hold a ``manual_workout``.
|
||||
"""Return how many ``manual_workout`` ENTRIES fall in the trailing window.
|
||||
|
||||
The manual-workout budget is counted per DAY: several self-reports on one
|
||||
day consume a single slot (and only one counts toward the weekly total
|
||||
anyway), so a day contributes at most one here.
|
||||
Counted per entry, not per day: each logged manual workout consumes its
|
||||
own budget slot, matching how each one also earns its own weekly-count
|
||||
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
|
||||
and ``screen_locker._workout_credit``) — the budget is the sole limiter on
|
||||
how many you can log, not a once-per-day collapse.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
@ -88,11 +90,11 @@ def count_in_window(
|
||||
parsed = _parse_iso(date_str)
|
||||
if parsed is None or not (cutoff < parsed <= today_dt):
|
||||
continue
|
||||
if any(
|
||||
entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
|
||||
count += sum(
|
||||
1
|
||||
for entry in entries
|
||||
):
|
||||
count += 1
|
||||
if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
WEEKLY_WORKOUT_MINIMUM: int = 4
|
||||
WEEKLY_WORKOUT_MINIMUM: int = 5
|
||||
|
||||
# Python weekday(): Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
|
||||
_RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
|
||||
@ -33,8 +33,10 @@ _RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
|
||||
VERIFIED_WORKOUT_TYPES: frozenset[str] = frozenset(
|
||||
{"phone_verified", "runnerup_verified"},
|
||||
)
|
||||
# The single self-reported type. It counts at most ONCE per day (see
|
||||
# count_weekly_workouts) so it can't be used to inflate the weekly total.
|
||||
# The single self-reported type. It counts toward the weekly total
|
||||
# INDIVIDUALLY, same as a verified workout — the manual-workout rate budget
|
||||
# (see screen_locker._manual_workout) is the anti-gaming limiter, not a
|
||||
# once-per-day collapse here.
|
||||
MANUAL_WORKOUT_TYPE: str = "manual_workout"
|
||||
# Types that count toward the weekly minimum *and* are eligible for the base
|
||||
# shutdown-time bonus (see screen_lock._try_adjust_shutdown_for_workout).
|
||||
@ -61,21 +63,21 @@ def count_weekly_workouts(
|
||||
*,
|
||||
today: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count counted workouts in the current ISO week under the anti-gaming rule.
|
||||
"""Count counted workouts in the current ISO week.
|
||||
|
||||
Each VERIFIED workout (:data:`VERIFIED_WORKOUT_TYPES`) counts individually,
|
||||
so multiple real workouts on one day all count. Self-reported
|
||||
``manual_workout`` entries count at most ONCE per day, so they can't inflate
|
||||
the total. Feeds both the weekly lock minimum (:func:`has_weekly_minimum`)
|
||||
and the banked bonus.
|
||||
Every entry whose type is in :data:`COUNTED_WORKOUT_TYPES` — verified or
|
||||
manual — counts individually, so multiple workouts on one day all count.
|
||||
Manual workouts are rate-limited separately by the manual-workout budget
|
||||
(:mod:`screen_locker._manual_workout`), not by a once-per-day collapse
|
||||
here. Feeds both the weekly lock minimum (:func:`has_weekly_minimum`) and
|
||||
the banked bonus.
|
||||
|
||||
Args:
|
||||
log_file: Path to ``workout_log.json``.
|
||||
today: Override for the current local datetime (for testing).
|
||||
|
||||
Returns:
|
||||
The weekly workout count (Mon-Sun, up to and including today) under the
|
||||
verified-stack / manual-once-per-day rule.
|
||||
The weekly workout count (Mon-Sun, up to and including today).
|
||||
"""
|
||||
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
|
||||
week_start = (dt - timedelta(days=dt.weekday())).date()
|
||||
@ -99,15 +101,10 @@ def count_weekly_workouts(
|
||||
continue
|
||||
if not (week_start <= entry_date <= today_date):
|
||||
continue
|
||||
has_manual = False
|
||||
for entry in entries:
|
||||
wtype = entry.get("workout_data", {}).get("type", "")
|
||||
if wtype in VERIFIED_WORKOUT_TYPES:
|
||||
count += 1 # each verified workout counts
|
||||
elif wtype == MANUAL_WORKOUT_TYPE:
|
||||
has_manual = True
|
||||
if has_manual:
|
||||
count += 1 # all of a day's manual entries count as one
|
||||
if wtype in COUNTED_WORKOUT_TYPES:
|
||||
count += 1 # each counted workout — verified or manual — counts
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@ -14,11 +14,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._weekly_check import (
|
||||
COUNTED_WORKOUT_TYPES,
|
||||
VERIFIED_WORKOUT_TYPES,
|
||||
count_weekly_workouts,
|
||||
)
|
||||
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -76,59 +72,40 @@ class WorkoutCreditMixin:
|
||||
_sick_tracker.save_history(history)
|
||||
return new_debt
|
||||
|
||||
def _apply_workout_credit(self) -> WorkoutCreditResult:
|
||||
"""Append ``workout_data`` and apply its reward, scaled to the day.
|
||||
def _apply_credit_for_written_entry(
|
||||
self, prior_entries: list[dict]
|
||||
) -> WorkoutCreditResult:
|
||||
"""Apply the shutdown/debt reward for an already-written ``workout_data``.
|
||||
|
||||
Shared by the locked-screen flow (:meth:`ScreenLocker.unlock_screen`),
|
||||
the voluntary ``StatusWindow`` "Log Manual Workout" path, and the
|
||||
Check-Phone verify path. Rewards, from the day's entries *before* this
|
||||
one (via the write chokepoint's :class:`RecordResult`):
|
||||
Shared by :meth:`_apply_workout_credit` (the live locked-screen,
|
||||
Status-Window, and Check-Phone paths, which always write under today)
|
||||
and the manual-sync ingestion path
|
||||
(:func:`screen_locker._manual_sync.ingest_manual_records`, via its
|
||||
``on_ingested`` callback — which writes under the entry's own date,
|
||||
today or back-dated). ``prior_entries`` must be that entry's day's
|
||||
entries *before* it, so "first counted workout of the day" is
|
||||
evaluated relative to the entry's own date; the shutdown adjustment
|
||||
itself always lands on the current shutdown config, since there's
|
||||
only ever one "tonight" to push regardless of which day earned it.
|
||||
|
||||
Rewards:
|
||||
|
||||
* duplicate (same ``workout_id`` already logged) → no new credit;
|
||||
* first counted workout of the day → base **+2h** (cap 23:00);
|
||||
* an additional same-day VERIFIED workout → **+1h** (cap midnight);
|
||||
* an additional same-day manual workout → no shutdown credit
|
||||
(self-reports can't stack intra-day credit — anti-gaming).
|
||||
|
||||
This method always files under *today*, so its credit is the intra-day
|
||||
reward; back-dated writes (manual-sync, RunnerUp backfill) go straight
|
||||
through the write chokepoint and earn only the weekly count, never a
|
||||
push to tonight's shutdown.
|
||||
* an additional same-day counted workout (verified OR manual) →
|
||||
**+1h** (cap midnight) — manual workouts stack exactly like
|
||||
verified ones; the manual-workout rate budget is the only limiter.
|
||||
"""
|
||||
# sick_day is already persisted to sick_history.json by
|
||||
# _finalize_sick_day — workout_log.json is reserved for real outcomes.
|
||||
if self.workout_data.get("type") == "sick_day":
|
||||
return WorkoutCreditResult(
|
||||
shutdown_adjusted=False,
|
||||
new_debt=None,
|
||||
extra_bonus_delta=0,
|
||||
weekly_count=count_weekly_workouts(self.log_file),
|
||||
already_counted_today=False,
|
||||
)
|
||||
|
||||
result = self.save_workout_log()
|
||||
weekly_count = count_weekly_workouts(self.log_file)
|
||||
|
||||
if not result.appended:
|
||||
# A workout with this id was already recorded today — idempotent.
|
||||
return WorkoutCreditResult(
|
||||
shutdown_adjusted=False,
|
||||
new_debt=None,
|
||||
extra_bonus_delta=0,
|
||||
weekly_count=weekly_count,
|
||||
already_counted_today=True,
|
||||
)
|
||||
|
||||
first_counted_today = not any(
|
||||
entry.get("workout_data", {}).get("type") in COUNTED_WORKOUT_TYPES
|
||||
for entry in result.prior_entries
|
||||
for entry in prior_entries
|
||||
)
|
||||
|
||||
shutdown_adjusted = False
|
||||
extra_bonus_delta = 0
|
||||
if first_counted_today:
|
||||
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
|
||||
elif self.workout_data.get("type") in VERIFIED_WORKOUT_TYPES:
|
||||
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):
|
||||
new_cfg = self._read_shutdown_config()
|
||||
@ -144,3 +121,38 @@ class WorkoutCreditMixin:
|
||||
weekly_count=weekly_count,
|
||||
already_counted_today=False,
|
||||
)
|
||||
|
||||
def _apply_workout_credit(self) -> WorkoutCreditResult:
|
||||
"""Append ``workout_data`` and apply its reward, scaled to the day.
|
||||
|
||||
Shared by the locked-screen flow (:meth:`ScreenLocker.unlock_screen`),
|
||||
the voluntary ``StatusWindow`` "Log Manual Workout" path, and the
|
||||
Check-Phone verify path. Always files under *today*; see
|
||||
:meth:`_apply_credit_for_written_entry` for the shared reward logic
|
||||
(a duplicate — same ``workout_id`` already logged — earns no new
|
||||
credit).
|
||||
"""
|
||||
# sick_day is already persisted to sick_history.json by
|
||||
# _finalize_sick_day — workout_log.json is reserved for real outcomes.
|
||||
if self.workout_data.get("type") == "sick_day":
|
||||
return WorkoutCreditResult(
|
||||
shutdown_adjusted=False,
|
||||
new_debt=None,
|
||||
extra_bonus_delta=0,
|
||||
weekly_count=count_weekly_workouts(self.log_file),
|
||||
already_counted_today=False,
|
||||
)
|
||||
|
||||
result = self.save_workout_log()
|
||||
|
||||
if not result.appended:
|
||||
# A workout with this id was already recorded today — idempotent.
|
||||
return WorkoutCreditResult(
|
||||
shutdown_adjusted=False,
|
||||
new_debt=None,
|
||||
extra_bonus_delta=0,
|
||||
weekly_count=count_weekly_workouts(self.log_file),
|
||||
already_counted_today=True,
|
||||
)
|
||||
|
||||
return self._apply_credit_for_written_entry(result.prior_entries)
|
||||
|
||||
@ -218,11 +218,41 @@ class ScreenLocker(
|
||||
self._check_heat_skip_exit()
|
||||
|
||||
def _ingest_synced_manual_workouts(self) -> None:
|
||||
"""Sync manual workouts: publish this PC's, ingest everyone else's."""
|
||||
"""Sync manual workouts: publish this PC's, ingest everyone else's.
|
||||
|
||||
Each newly-ingested record earns the identical shutdown/debt reward a
|
||||
live-logged workout would (see
|
||||
``WorkoutCreditMixin._apply_credit_for_written_entry``), regardless of
|
||||
whether it's dated today or back-dated — there's only one current
|
||||
shutdown config, so a back-dated sync still pushes it.
|
||||
"""
|
||||
push_pc_workouts(self.log_file)
|
||||
ingested = ingest_manual_records(self.log_file, pull_all_manual_records())
|
||||
ingested = ingest_manual_records(
|
||||
self.log_file,
|
||||
pull_all_manual_records(),
|
||||
on_ingested=self._credit_ingested_manual_workout,
|
||||
)
|
||||
for record_id in ingested:
|
||||
_logger.info("Ingested synced manual workout: %s", record_id)
|
||||
self.workout_data = {}
|
||||
|
||||
def _credit_ingested_manual_workout(
|
||||
self, entry: dict[str, str], prior_entries: list[dict]
|
||||
) -> None:
|
||||
"""Apply the live-workout reward to a manual workout ingested via sync."""
|
||||
self.workout_data = entry
|
||||
credit = self._apply_credit_for_written_entry(prior_entries)
|
||||
if credit.shutdown_adjusted:
|
||||
_logger.info(
|
||||
"Synced manual workout pushed shutdown time +2h: %s",
|
||||
entry.get("source", ""),
|
||||
)
|
||||
elif credit.extra_bonus_delta:
|
||||
_logger.info(
|
||||
"Synced manual workout added +%dh shutdown time: %s",
|
||||
credit.extra_bonus_delta,
|
||||
entry.get("source", ""),
|
||||
)
|
||||
|
||||
def _auto_fill_week_runnerup_bonus(self) -> None:
|
||||
"""Auto-fill missed RunnerUp workouts and award any earned bonus."""
|
||||
|
||||
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from screen_locker import _manual_sync
|
||||
from screen_locker._log_mixin import RecordResult
|
||||
from screen_locker._manual_sync import (
|
||||
ingest_manual_records,
|
||||
reconstruct_draft,
|
||||
@ -172,6 +173,67 @@ class TestIngestManualRecords:
|
||||
assert "hmac" not in stored[_DATE][0]
|
||||
|
||||
|
||||
class TestIngestManualRecordsCallback:
|
||||
"""Tests for the ``on_ingested`` callback that drives shutdown/debt credit."""
|
||||
|
||||
def test_on_ingested_called_with_entry_and_prior_entries(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(
|
||||
json.dumps({_DATE: [{"workout_data": {"type": "phone_verified"}}]})
|
||||
)
|
||||
record = _manual_record(_TT_DRAFT)
|
||||
calls: list[tuple[dict, list[dict]]] = []
|
||||
ingest_manual_records(
|
||||
log_file,
|
||||
[record],
|
||||
today=_DATE,
|
||||
on_ingested=lambda entry, prior: calls.append((entry, prior)),
|
||||
)
|
||||
assert len(calls) == 1
|
||||
entry, prior = calls[0]
|
||||
assert entry["type"] == "manual_workout"
|
||||
assert entry["sync_record_id"] == record[0]
|
||||
assert prior == [{"workout_data": {"type": "phone_verified"}}]
|
||||
|
||||
def test_on_ingested_not_called_when_budget_exhausted(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
record = _manual_record(_TT_DRAFT)
|
||||
calls: list[tuple[dict, list[dict]]] = []
|
||||
with patch.object(_manual_sync, "is_budget_exhausted", return_value=True):
|
||||
ingest_manual_records(
|
||||
log_file,
|
||||
[record],
|
||||
today=_DATE,
|
||||
on_ingested=lambda entry, prior: calls.append((entry, prior)),
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
def test_write_chokepoint_collision_is_not_credited_or_returned(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""If the write chokepoint's own workout_id dedup no-ops the write
|
||||
(``appended=False``), the record must not be reported as ingested or
|
||||
handed to ``on_ingested`` — crediting a write that never happened
|
||||
would be a real correctness bug, not just a missed optimization."""
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
record = _manual_record(_TT_DRAFT)
|
||||
calls: list[tuple[dict, list[dict]]] = []
|
||||
with patch(
|
||||
"screen_locker._manual_sync.write_signed_entry",
|
||||
return_value=RecordResult(appended=False, prior_entries=[]),
|
||||
):
|
||||
ingested = ingest_manual_records(
|
||||
log_file,
|
||||
[record],
|
||||
today=_DATE,
|
||||
on_ingested=lambda entry, prior: calls.append((entry, prior)),
|
||||
)
|
||||
assert ingested == []
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestIngestHelpers:
|
||||
def test_already_ingested_finds_id_in_per_day_lists(self) -> None:
|
||||
"""_already_ingested takes normalized ``{date: [entry, ...]}`` and
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -102,6 +103,23 @@ class TestCountInWindow:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
assert count_in_window(log_file, 7) == 0
|
||||
|
||||
def test_multiple_same_day_entries_each_count(self, tmp_path: Path) -> None:
|
||||
"""Per-entry counting: same-day manual workouts no longer collapse to
|
||||
one slot — each consumes its own, matching the new weekly-count
|
||||
parity with verified workouts."""
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"2026-07-04": [
|
||||
{"workout_data": {"type": "manual_workout"}},
|
||||
{"workout_data": {"type": "manual_workout"}},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert count_in_window(log_file, 7, today=_TODAY) == 2
|
||||
|
||||
|
||||
class TestIsBudgetExhausted:
|
||||
"""Tests for is_budget_exhausted."""
|
||||
@ -121,12 +139,15 @@ class TestIsBudgetExhausted:
|
||||
|
||||
def test_true_when_monthly_exhausted(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
# All strictly after the 30d cutoff (2026-06-05) but at/before the
|
||||
# 7d cutoff (2026-06-28), so only the 30d window sees them.
|
||||
dates = ["2026-06-08", "2026-06-13", "2026-06-18", "2026-06-23", "2026-06-28"]
|
||||
entries = dict.fromkeys(
|
||||
dates[:MANUAL_WORKOUT_BUDGET_PER_30_DAYS], "manual_workout"
|
||||
)
|
||||
# MANUAL_WORKOUT_BUDGET_PER_30_DAYS distinct dates, all strictly after
|
||||
# the 30d cutoff (2026-06-05) but at/before the 7d cutoff (2026-06-28),
|
||||
# so only the 30d window sees them.
|
||||
today_dt = datetime.strptime(_TODAY, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
dates = [
|
||||
(today_dt - timedelta(days=8 + i)).strftime("%Y-%m-%d")
|
||||
for i in range(MANUAL_WORKOUT_BUDGET_PER_30_DAYS)
|
||||
]
|
||||
entries = dict.fromkeys(dates, "manual_workout")
|
||||
_write_logs(log_file, entries)
|
||||
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
||||
assert is_budget_exhausted(log_file, today=_TODAY) is True
|
||||
|
||||
@ -139,14 +139,15 @@ class TestApplyWorkoutCredit:
|
||||
assert result.extra_bonus_delta == 0
|
||||
locker._try_adjust_shutdown_for_workout.assert_called_once()
|
||||
|
||||
def test_additional_manual_workout_earns_no_shutdown_credit(
|
||||
def test_additional_manual_workout_earns_plus_one_hour(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A second manual workout on a day that already counted → no shutdown push.
|
||||
"""A second manual workout on a day that already counted → +1h extra.
|
||||
|
||||
Anti-gaming: self-reports can't stack intra-day shutdown credit. The
|
||||
prior day already holds a counted entry, and the new workout is manual
|
||||
(not verified), so neither the base push nor the +1h extra applies.
|
||||
Manual workouts now stack intra-day shutdown credit exactly like
|
||||
verified ones — the rate budget (not this stacking rule) is the sole
|
||||
limiter on manual workouts. Mirrors
|
||||
``test_additional_verified_workout_earns_plus_one_hour``.
|
||||
"""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "manual_workout"}
|
||||
@ -159,19 +160,27 @@ class TestApplyWorkoutCredit:
|
||||
object.__setattr__(
|
||||
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_read_shutdown_config",
|
||||
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
|
||||
)
|
||||
with patch(
|
||||
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
|
||||
):
|
||||
result = locker._apply_workout_credit()
|
||||
|
||||
result = locker._apply_workout_credit()
|
||||
|
||||
assert result.weekly_count == 5
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert result.extra_bonus_delta == 1
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(1)
|
||||
|
||||
def test_additional_verified_workout_earns_plus_one_hour(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -215,6 +224,71 @@ class TestApplyWorkoutCredit:
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(1)
|
||||
|
||||
def test_additional_workout_unreadable_shutdown_config_earns_no_bonus(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Additional same-day workout, but the shutdown config can't be read
|
||||
(old_cfg is None) → the +1h branch is entered but bails out before
|
||||
ever attempting the adjustment."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "phone_verified"}
|
||||
prior = [{"workout_data": {"type": "phone_verified"}}]
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"save_workout_log",
|
||||
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_read_shutdown_config", MagicMock(return_value=None)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
|
||||
)
|
||||
|
||||
result = locker._apply_workout_credit()
|
||||
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 0
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
def test_additional_workout_of_uncounted_type_earns_nothing(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A workout whose type isn't in COUNTED_WORKOUT_TYPES (e.g.
|
||||
``early_bird``) and isn't the first counted entry of the day →
|
||||
neither the base push nor the +1h branch fires; the shutdown config
|
||||
is never even consulted."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "early_bird"}
|
||||
prior = [{"workout_data": {"type": "phone_verified"}}]
|
||||
object.__setattr__(
|
||||
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
|
||||
)
|
||||
|
||||
result = locker._apply_credit_for_written_entry(prior)
|
||||
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 0
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._read_shutdown_config.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
def test_skips_save_for_sick_day_type(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@ -43,7 +43,7 @@ class TestCheckNonVerifyExitsExtras:
|
||||
with (
|
||||
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
|
||||
patch(
|
||||
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[0, 5]
|
||||
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[0, 6]
|
||||
),
|
||||
patch(
|
||||
"screen_locker.screen_lock.process_week_transition",
|
||||
@ -54,7 +54,7 @@ class TestCheckNonVerifyExitsExtras:
|
||||
patch("screen_locker.screen_lock.sys.exit"),
|
||||
):
|
||||
locker._check_non_verify_exits()
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(1) # bonus = 5-max(4,0)
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(1) # bonus = 6-max(5,0)
|
||||
|
||||
def test_auto_fill_no_bonus_when_min_not_exceeded(
|
||||
self,
|
||||
@ -76,7 +76,7 @@ class TestCheckNonVerifyExitsExtras:
|
||||
)
|
||||
with (
|
||||
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
|
||||
# prev=2, new=3 → bonus=max(0,3-max(4,2))=0 → no bonus logger call
|
||||
# prev=2, new=3 → bonus=max(0,3-max(5,2))=0 → no bonus logger call
|
||||
patch(
|
||||
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[2, 3]
|
||||
),
|
||||
|
||||
@ -298,3 +298,83 @@ class TestIngestSyncedManualWorkouts:
|
||||
),
|
||||
):
|
||||
locker._ingest_synced_manual_workouts()
|
||||
|
||||
def test_passes_credit_callback_and_resets_workout_data(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The callback wired into ingest_manual_records must be
|
||||
``locker._credit_ingested_manual_workout``, so a synced manual
|
||||
workout earns the same reward a live one would; workout_data is reset
|
||||
afterward so it can't leak into a later interactive flow."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"stale": "state"}
|
||||
with (
|
||||
patch(
|
||||
"screen_locker.screen_lock.pull_all_manual_records",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"screen_locker.screen_lock.ingest_manual_records",
|
||||
return_value=[],
|
||||
) as ingest,
|
||||
):
|
||||
locker._ingest_synced_manual_workouts()
|
||||
assert ingest.call_args.kwargs["on_ingested"] == (
|
||||
locker._credit_ingested_manual_workout
|
||||
)
|
||||
assert locker.workout_data == {}
|
||||
|
||||
def test_credit_ingested_manual_workout_applies_reward(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""_credit_ingested_manual_workout sets workout_data to the ingested
|
||||
entry and applies the shared credit logic — proving a synced manual
|
||||
workout (today's or back-dated) earns full shutdown/debt credit,
|
||||
exactly like a live-logged one."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
entry = {"type": "manual_workout", "source": "table tennis at Solec"}
|
||||
prior: list[dict] = []
|
||||
with patch.object(
|
||||
locker,
|
||||
"_apply_credit_for_written_entry",
|
||||
return_value=MagicMock(shutdown_adjusted=True, extra_bonus_delta=0),
|
||||
) as apply_credit:
|
||||
locker._credit_ingested_manual_workout(entry, prior)
|
||||
assert locker.workout_data == entry
|
||||
apply_credit.assert_called_once_with(prior)
|
||||
|
||||
def test_credit_ingested_manual_workout_logs_extra_bonus(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A second same-day synced manual workout (extra_bonus_delta, not the
|
||||
base push) takes the elif branch — a back-dated sync can still stack
|
||||
the +1h bonus exactly like an additional same-day verified workout."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
entry = {"type": "manual_workout", "source": "gym"}
|
||||
prior = [{"workout_data": {"type": "manual_workout"}}]
|
||||
with patch.object(
|
||||
locker,
|
||||
"_apply_credit_for_written_entry",
|
||||
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=1),
|
||||
) as apply_credit:
|
||||
locker._credit_ingested_manual_workout(entry, prior)
|
||||
assert locker.workout_data == entry
|
||||
apply_credit.assert_called_once_with(prior)
|
||||
|
||||
def test_credit_ingested_manual_workout_no_reward_logs_nothing(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A duplicate/no-op credit result (neither shutdown_adjusted nor
|
||||
extra_bonus_delta) → neither log line fires; credit is still applied
|
||||
via workout_data being set."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
entry = {"type": "manual_workout", "source": "gym"}
|
||||
prior: list[dict] = []
|
||||
with patch.object(
|
||||
locker,
|
||||
"_apply_credit_for_written_entry",
|
||||
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=0),
|
||||
) as apply_credit:
|
||||
locker._credit_ingested_manual_workout(entry, prior)
|
||||
assert locker.workout_data == entry
|
||||
apply_credit.assert_called_once_with(prior)
|
||||
|
||||
@ -250,12 +250,13 @@ class TestWeeklyTotalUsesPerWorkoutRule:
|
||||
def test_multiple_same_day_workouts_all_count(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""One day with 1 manual + 3 verified entries = 4, not capped at 1.
|
||||
"""One day with 2 manual + 3 verified entries = 5, not capped at 1.
|
||||
|
||||
Regression test: the summary used to derive its total from a
|
||||
per-day checkmark count (max 1 per day), undercounting any day
|
||||
holding more than one counted workout — exactly what multi-workout
|
||||
days are supposed to support.
|
||||
days are supposed to support. Also covers manual workouts counting
|
||||
individually (no once-per-day collapse), same as verified ones.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@ -269,6 +270,7 @@ class TestWeeklyTotalUsesPerWorkoutRule:
|
||||
{"workout_data": {"type": "runnerup_verified", "source": "a"}},
|
||||
{"workout_data": {"type": "phone_verified", "source": "b"}},
|
||||
{"workout_data": {"type": "runnerup_verified", "source": "c"}},
|
||||
{"workout_data": {"type": "manual_workout", "source": "gym2"}},
|
||||
]
|
||||
}
|
||||
)
|
||||
@ -284,4 +286,4 @@ class TestWeeklyTotalUsesPerWorkoutRule:
|
||||
):
|
||||
run_status(locker)
|
||||
out = capsys.readouterr().out
|
||||
assert "Weekly minimum met exactly (4/4)." in out
|
||||
assert "Weekly minimum met exactly (5/5)." in out
|
||||
|
||||
@ -46,7 +46,7 @@ class TestGatherStatus:
|
||||
assert snap.today.counted is False
|
||||
assert snap.today.is_sick_day is False
|
||||
assert snap.week.counted_count == 0
|
||||
assert snap.week.remaining == 4
|
||||
assert snap.week.remaining == 5
|
||||
assert snap.week.extra == 0
|
||||
assert snap.shutdown.tonight is None
|
||||
assert snap.shutdown.rest_of_week[0].hour == 21
|
||||
@ -161,7 +161,13 @@ class TestGatherStatus:
|
||||
json.dumps(
|
||||
{
|
||||
d: {"workout_data": {"type": "phone_verified"}}
|
||||
for d in ("2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04")
|
||||
for d in (
|
||||
"2024-01-01",
|
||||
"2024-01-02",
|
||||
"2024-01-03",
|
||||
"2024-01-04",
|
||||
"2024-01-05",
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -170,24 +176,28 @@ class TestGatherStatus:
|
||||
):
|
||||
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
|
||||
|
||||
assert snap.week.counted_count == 4
|
||||
assert snap.week.counted_count == 5
|
||||
assert snap.week.remaining == 0
|
||||
assert snap.week.extra == 0
|
||||
assert snap.lock_explanation.stage == "weekly_minimum_met"
|
||||
|
||||
def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None:
|
||||
files = _files(tmp_path)
|
||||
# Jan 1-4 one each, Jan 5 (today, Friday — the last day in-week without
|
||||
# going into the future) holds two, for 6 counted total: 1 above the
|
||||
# new minimum of 5. Also exercises multiple same-day entries each
|
||||
# counting individually.
|
||||
files["log_file"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
d: {"workout_data": {"type": "phone_verified"}}
|
||||
for d in (
|
||||
"2024-01-01",
|
||||
"2024-01-02",
|
||||
"2024-01-03",
|
||||
"2024-01-04",
|
||||
"2024-01-05",
|
||||
)
|
||||
"2024-01-01": {"workout_data": {"type": "phone_verified"}},
|
||||
"2024-01-02": {"workout_data": {"type": "phone_verified"}},
|
||||
"2024-01-03": {"workout_data": {"type": "phone_verified"}},
|
||||
"2024-01-04": {"workout_data": {"type": "phone_verified"}},
|
||||
"2024-01-05": [
|
||||
{"workout_data": {"type": "phone_verified"}},
|
||||
{"workout_data": {"type": "phone_verified"}},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -326,7 +336,13 @@ class TestFormatSummaryLine:
|
||||
json.dumps(
|
||||
{
|
||||
d: {"workout_data": {"type": "phone_verified"}}
|
||||
for d in ("2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04")
|
||||
for d in (
|
||||
"2024-01-01",
|
||||
"2024-01-02",
|
||||
"2024-01-03",
|
||||
"2024-01-04",
|
||||
"2024-01-05",
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@ -26,7 +26,7 @@ class TestRunStatusFill:
|
||||
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown.
|
||||
|
||||
count_weekly_workouts is called twice: before the fill (3) and after
|
||||
(5) — a flat return_value would collapse both calls to the same
|
||||
(6) — a flat return_value would collapse both calls to the same
|
||||
number and mask the bonus math, so this uses side_effect instead.
|
||||
"""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
@ -35,7 +35,7 @@ class TestRunStatusFill:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[3, 5]),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[3, 6]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
@ -53,7 +53,7 @@ class TestRunStatusFill:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 6]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
@ -63,7 +63,7 @@ class TestRunStatusFill:
|
||||
def test_fill_no_bonus_when_still_below_min(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""n_filled=1 but count still < 4, before and after the fill → no bonus."""
|
||||
"""n_filled=1 but count still < 5, before and after the fill → no bonus."""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
|
||||
with (
|
||||
@ -87,7 +87,7 @@ class TestRunStatusMinimumStatus:
|
||||
"""after_count > WEEKLY_WORKOUT_MINIMUM → 'above minimum' line.
|
||||
|
||||
n_filled=1 triggers the count_weekly_workouts() branch so after_count
|
||||
is taken from the second mocked call (5), not the first (before_count).
|
||||
is taken from the second mocked call (6), not the first (before_count).
|
||||
"""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
|
||||
@ -95,7 +95,7 @@ class TestRunStatusMinimumStatus:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 6]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
@ -107,8 +107,8 @@ class TestRunStatusMinimumStatus:
|
||||
) -> None:
|
||||
"""after_count == WEEKLY_WORKOUT_MINIMUM → 'met exactly' line.
|
||||
|
||||
n_filled=1 so after_count = count_weekly_workouts() = 4 = WEEKLY_WORKOUT_MINIMUM.
|
||||
bonus = max(0, 4 - max(4, 0)) = 0, so no bonus line is printed.
|
||||
n_filled=1 so after_count = count_weekly_workouts() = 5 = WEEKLY_WORKOUT_MINIMUM.
|
||||
bonus = max(0, 5 - max(5, 0)) = 0, so no bonus line is printed.
|
||||
"""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
|
||||
@ -116,7 +116,7 @@ class TestRunStatusMinimumStatus:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 4]),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
|
||||
@ -284,7 +284,7 @@ class TestBackfillWeekAndApplyBonus:
|
||||
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.count_weekly_workouts",
|
||||
side_effect=[4, 6],
|
||||
side_effect=[4, 7],
|
||||
):
|
||||
message = _backfill_week_and_apply_bonus(verifier)
|
||||
|
||||
|
||||
@ -189,6 +189,22 @@ class TestCountWeeklyWorkouts:
|
||||
)
|
||||
assert count_weekly_workouts(log, today=_dt(4)) == 2
|
||||
|
||||
def test_multiple_same_day_manual_workouts_each_count(self, tmp_path: Path) -> None:
|
||||
"""Manual workouts count individually now, same as verified — no
|
||||
once-per-day collapse."""
|
||||
log = tmp_path / "workout_log.json"
|
||||
log.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"2025-05-19": [
|
||||
{"workout_data": {"type": "manual_workout"}},
|
||||
{"workout_data": {"type": "manual_workout"}},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert count_weekly_workouts(log, today=_dt(4)) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_weekly_minimum
|
||||
@ -212,7 +228,7 @@ class TestHasWeeklyMinimum:
|
||||
)
|
||||
assert has_weekly_minimum(log, today=_dt(4)) is False
|
||||
|
||||
def test_four_workouts_is_true(self, tmp_path: Path) -> None:
|
||||
def test_four_workouts_is_false(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "workout_log.json"
|
||||
_make_log(
|
||||
{
|
||||
@ -223,7 +239,7 @@ class TestHasWeeklyMinimum:
|
||||
},
|
||||
log,
|
||||
)
|
||||
assert has_weekly_minimum(log, today=_dt(4)) is True
|
||||
assert has_weekly_minimum(log, today=_dt(4)) is False
|
||||
|
||||
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "workout_log.json"
|
||||
@ -240,4 +256,4 @@ class TestHasWeeklyMinimum:
|
||||
assert has_weekly_minimum(log, today=_dt(4)) is True
|
||||
|
||||
def test_weekly_workout_minimum_constant(self) -> None:
|
||||
assert WEEKLY_WORKOUT_MINIMUM == 4
|
||||
assert WEEKLY_WORKOUT_MINIMUM == 5
|
||||
|
||||
@ -230,7 +230,7 @@ class TestStartRelaxedDayFlow:
|
||||
|
||||
all_text = " ".join(str(c) for c in mock_text.call_args_list)
|
||||
assert "2" in all_text
|
||||
assert "4" in all_text
|
||||
assert "5" in all_text
|
||||
|
||||
def test_skip_button_wires_close(
|
||||
self,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user