screen-locker/screen_locker/tests/test_weekly_check_dedup.py
Krzysztof kuhy Rudnicki 8ac7af158d Count one StrongLifts session once, not once per ingestion path
The locker did not fire on 2026-08-29, or for the two weeks before it.
Every 30 minutes it logged:

  DECISION lock=no reason=weekly_minimum_met weekly=5/5

The 5 was wrong. A StrongLifts session is ingested twice -- the phone sync
writes phone_verified, the PC session sync writes pc_workout_verified, both
from the same WorkoutSession payload -- and workout_id is "<type>:<date>",
so the two copies get different IDs and neither dedups the other away.
count_weekly_workouts then counted each entry individually.

Real totals against what was counted:

  week of 2026-08-17    5 counted    4 real
  week of 2026-08-24    5 counted    3 real

Both weeks were waived. Nothing reported a problem, because 5/5 is exactly
what a compliant week looks like.

Add credit_key/count_day_credits to _weekly_check as the single rule for
"does this entry earn a credit, and is it the same credit as another": the
two StrongLifts ingestion paths share one slot per day, everything else
stays per-entry so two runs or two manual workouts in a day still count
twice. Exported, so counters share the rule instead of re-deriving it --
re-derivation is what caused this.

Route _status_data._day_status through it too. It had re-derived the rule
as "verified + (1 if has_manual else 0)", which disagreed with
count_weekly_workouts on any day with two manual workouts; since that value
feeds the status view's weekly_minimum_met explanation, the window could
explain a decision the locker never made.

Two existing fixtures reached their counts using two same-day
phone_verified entries -- the double-count itself. They now use a gym
session plus a run, which is two real workouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 22:23:17 +02:00

164 lines
5.8 KiB
Python

"""Tests for the StrongLifts credit-key dedup in _weekly_check.
Regression cover for 2026-08-29: one StrongLifts session is ingested through
two independent paths -- the phone sync (``phone_verified``) and the PC session
sync (``pc_workout_verified``) -- and ``workout_id`` is ``"<type>:<date>"``, so
the two copies of one workout got different IDs and both survived into
``log.json``. ``count_weekly_workouts`` then counted each entry individually,
the week reached 5/5 two workouts early, and ``has_weekly_minimum`` disabled
enforcement for two consecutive weeks without anything reporting a problem.
The fixtures below are the real log entries from those days, not invented ones:
the point of the bug is that the two halves look like separate workouts unless
you notice the matching durations.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
from typing import TYPE_CHECKING, Any
from screen_locker._weekly_check import (
count_weekly_workouts,
has_weekly_minimum,
)
if TYPE_CHECKING:
from pathlib import Path
# Saturday of the ISO week beginning Monday 2026-08-24 -- the day the locker
# was expected to fire and did not.
_SATURDAY_2026_08_29 = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
def _entry(wtype: str, *, timestamp: str, source: str = "") -> dict[str, Any]:
"""Build one log entry of the given workout type.
Args:
wtype: The ``workout_data.type`` value.
timestamp: ISO-8601 timestamp for the entry.
source: Human-readable provenance string, as the real log carries.
Returns:
A log entry dict in the current on-disk shape.
"""
return {
"timestamp": timestamp,
"workout_data": {"type": wtype, "source": source},
"workout_id": f"{wtype}:{timestamp[:10]}",
}
def _write_log(days: dict[str, list[dict[str, Any]]], log_file: Path) -> Path:
"""Write a log.json in the current ``{date: [entry, ...]}`` shape.
Args:
days: Mapping of ``YYYY-MM-DD`` to its list of entries.
log_file: Destination path.
Returns:
``log_file``, for convenience at call sites.
"""
log_file.write_text(json.dumps(days, indent=2), encoding="utf-8")
return log_file
def _real_week_2026_08_24() -> dict[str, list[dict[str, Any]]]:
"""Return the real log contents for the ISO week of 2026-08-24.
Three genuine workouts -- a StrongLifts session on Monday, padel on
Thursday, a StrongLifts session on Friday -- recorded as five entries,
because both StrongLifts sessions were ingested twice.
Returns:
The date-keyed log fragment for that week.
"""
return {
"2026-08-24": [
_entry(
"pc_workout_verified",
timestamp="2026-08-24T10:55:29.565673+00:00",
source="StrongLifts workout A (118 min, all succeeded)",
),
_entry(
"phone_verified",
timestamp="2026-08-24T10:55:38.274973+00:00",
source="Workout verified! (118 min, all succeeded)",
),
],
"2026-08-27": [
_entry(
"manual_workout",
timestamp="2026-08-27T18:45:32.328067+00:00",
source="padel at Padel PGE Narodowy",
),
],
"2026-08-28": [
_entry(
"phone_verified",
timestamp="2026-08-28T09:24:00.798562+00:00",
source="Workout verified! (113 min, all succeeded)",
),
_entry(
"pc_workout_verified",
timestamp="2026-08-28T09:30:07.715751+00:00",
source="StrongLifts workout B (113 min, all succeeded)",
),
],
}
class TestStrongliftsDoubleCountRegression:
"""The 2026-08-29 outage: five entries, three workouts, no lock."""
def test_real_week_counts_three_not_five(self, tmp_path: Path) -> None:
"""The week of 2026-08-24 holds three workouts, not five entries."""
log = _write_log(_real_week_2026_08_24(), tmp_path / "log.json")
assert count_weekly_workouts(log, today=_SATURDAY_2026_08_29) == 3
def test_real_week_does_not_meet_the_minimum(self, tmp_path: Path) -> None:
"""Three workouts is short of five, so enforcement must not be waived."""
log = _write_log(_real_week_2026_08_24(), tmp_path / "log.json")
assert has_weekly_minimum(log, today=_SATURDAY_2026_08_29) is False
def test_phone_and_pc_on_one_day_share_one_credit(
self,
tmp_path: Path,
) -> None:
"""Both ingestion paths for one session count once between them."""
log = _write_log(
{
"2026-08-24": [
_entry("phone_verified", timestamp="2026-08-24T10:55:38+00:00"),
_entry(
"pc_workout_verified",
timestamp="2026-08-24T10:55:29+00:00",
),
],
},
tmp_path / "log.json",
)
assert count_weekly_workouts(log, today=_SATURDAY_2026_08_29) == 1
def test_phone_and_pc_on_different_days_count_separately(
self,
tmp_path: Path,
) -> None:
"""The collapse is per-day: two days of StrongLifts are two workouts."""
log = _write_log(
{
"2026-08-24": [
_entry("phone_verified", timestamp="2026-08-24T10:00:00+00:00"),
],
"2026-08-25": [
_entry(
"pc_workout_verified",
timestamp="2026-08-25T10:00:00+00:00",
),
],
},
tmp_path / "log.json",
)
assert count_weekly_workouts(log, today=_SATURDAY_2026_08_29) == 2