screen-locker/screen_locker/tests/test_session_sync.py
Krzysztof kuhy Rudnicki a48bd1334a Credit StrongLifts sessions from the headless sync, as pc_workout_verified
`sync_now()` -- what workout-sync.timer runs every 15 minutes -- only ever
ingested MANUAL records. The session reader `pull_synced_workout()` was
reachable only from `_verify_phone_workout`, i.e. the interactive lock/status
UI, so a finished session earned nothing until a lock screen happened to open.

That was survivable while the phone was the only device recording sessions:
the same lock screen that demanded the workout also verified it. The Linux
build finishes sessions with no lock screen involved at all, so the credit
would simply never arrive.

`pull_all_session_records()` is the session counterpart to the existing
`pull_all_manual_records()` -- `pull_synced_workout` returns only the single
newest session for the live unlock check, which cannot backfill a week.

New type `pc_workout_verified` rather than reusing `phone_verified`: it is
machine-checked in exactly the same way (same WorkoutSession payload, same
set/rep and duration validation), but there is no phone involved, and writing
"phone_verified" into an HMAC-signed log would make the log lie about its own
provenance.

Ingestion re-derives the verdict instead of trusting the record's `succeeded`
flag -- the record arrives over the network and this is the thing handing out
unlock credit. Every rejection logs at WARNING with an actionable reason, per
the repo's no-silent-failures rule.

One real leak fixed along the way: formatting the duration with `.0f` rounded
34m59s up to "35 min", which is precisely the hidden accept bar -- the message
would have told the user the real threshold while rejecting them at it. It now
truncates, so the advertised 40 stays the only number shown.

Verified against live data: ingests the 4 real synced sessions, and a second
run ingests 0 (the write chokepoint dedups by workout_id), so the repeating
timer cannot double-credit. Suite green at 100% branch coverage, 1046 tests.

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

178 lines
6.5 KiB
Python

"""Tests for ingesting synced StrongLifts sessions into log.json."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._session_sync import (
build_session_entry,
ingest_session_records,
validate_session,
)
from screen_locker._weekly_check import PC_WORKOUT_TYPE, count_weekly_workouts
from screen_locker._workout_sync import pull_all_session_records
if TYPE_CHECKING:
from pathlib import Path
_DATE = "2026-08-21"
# The ISO week _DATE falls in, for count_weekly_workouts.
_TODAY = datetime(2026, 8, 21, tzinfo=timezone.utc)
def _session(**overrides: object) -> dict:
"""A session payload shaped like the app's WorkoutSession.toJson()."""
payload: dict = {
"workout_type": "B",
"date": _DATE,
"start_time": f"{_DATE}T10:39:24.531831",
"duration_seconds": 6474,
"succeeded": True,
"exercises": [{"name": "Dumbbell Row", "succeeded": True}],
}
payload.update(overrides)
return payload
class TestValidateSession:
"""The PC re-derives the verdict rather than trusting the record."""
def test_accepts_a_long_enough_session(self) -> None:
ok, detail = validate_session(_session())
assert ok
assert "108 min" in detail
def test_rejects_a_session_with_no_exercises(self) -> None:
ok, detail = validate_session(_session(exercises=[]))
assert not ok
assert "no exercises" in detail
def test_rejects_a_non_list_exercises_field(self) -> None:
ok, detail = validate_session(_session(exercises="Dumbbell Row"))
assert not ok
assert "no exercises" in detail
def test_rejects_a_short_session(self) -> None:
ok, detail = validate_session(_session(duration_seconds=10 * 60))
assert not ok
assert "10 min" in detail
def test_rejects_a_non_numeric_duration(self) -> None:
ok, detail = validate_session(_session(duration_seconds="lots"))
assert not ok
assert "not a number" in detail
def test_accepts_exactly_the_hidden_accept_threshold(self) -> None:
"""35 min passes; the message still advertises the round 40."""
ok, _ = validate_session(_session(duration_seconds=35 * 60))
assert ok
def test_rejects_just_below_the_accept_threshold(self) -> None:
ok, detail = validate_session(_session(duration_seconds=34 * 60 + 59))
assert not ok
# The real bar (35) must never leak into a user-visible string.
assert "35" not in detail
assert "40 min" in detail
class TestBuildSessionEntry:
"""The entry names the PC as the source, never the phone."""
def test_marks_the_workout_as_pc_verified(self) -> None:
entry = build_session_entry(_session(), "108 min")
assert entry["type"] == PC_WORKOUT_TYPE
assert entry["type"] != "phone_verified"
def test_describes_the_workout_and_its_outcome(self) -> None:
entry = build_session_entry(_session(), "108 min")
assert "workout B" in entry["source"]
assert "all succeeded" in entry["source"]
def test_reports_a_partial_workout_as_partial(self) -> None:
entry = build_session_entry(_session(succeeded=False), "107 min")
assert "partial" in entry["source"]
def test_carries_the_duration_and_record_id(self) -> None:
entry = build_session_entry(_session(), "108 min")
assert entry["duration_minutes"] == "107.9"
assert entry["sync_record_id"] == f"{_DATE}T10:39:24.531831"
class TestIngestSessionRecords:
"""Ingestion is idempotent and credits only real workouts."""
def test_appends_a_verified_session_under_its_own_date(
self, tmp_path: Path
) -> None:
log = tmp_path / "log.json"
ingested = ingest_session_records(log, [("r1", _session())])
assert ingested == ["r1"]
assert count_weekly_workouts(log, today=_TODAY) == 1
def test_is_idempotent_across_repeated_syncs(self, tmp_path: Path) -> None:
"""The 15-minute timer re-sees every record; it must not re-credit."""
log = tmp_path / "log.json"
records = [("r1", _session())]
assert ingest_session_records(log, records) == ["r1"]
assert ingest_session_records(log, records) == []
assert count_weekly_workouts(log, today=_TODAY) == 1
def test_skips_a_session_with_no_date(self, tmp_path: Path) -> None:
payload = _session()
del payload["date"]
assert ingest_session_records(tmp_path / "log.json", [("r1", payload)]) == []
def test_skips_a_session_whose_date_is_not_a_string(self, tmp_path: Path) -> None:
assert (
ingest_session_records(tmp_path / "log.json", [("r1", _session(date=7))])
== []
)
def test_skips_a_session_that_fails_validation(self, tmp_path: Path) -> None:
log = tmp_path / "log.json"
short = _session(duration_seconds=60)
assert ingest_session_records(log, [("r1", short)]) == []
assert count_weekly_workouts(log, today=_TODAY) == 0
def test_calls_back_for_each_newly_ingested_session(self, tmp_path: Path) -> None:
seen: list[tuple[dict, list]] = []
ingest_session_records(
tmp_path / "log.json",
[("r1", _session())],
on_ingested=lambda entry, prior: seen.append((entry, prior)),
)
assert len(seen) == 1
assert seen[0][0]["type"] == PC_WORKOUT_TYPE
assert seen[0][1] == []
def test_does_not_call_back_for_a_duplicate(self, tmp_path: Path) -> None:
log = tmp_path / "log.json"
records = [("r1", _session())]
ingest_session_records(log, records)
seen: list[dict] = []
ingest_session_records(
log, records, on_ingested=lambda entry, _prior: seen.append(entry)
)
assert seen == []
class TestPullAllSessionRecords:
"""Sessions come from every device log, or from none at all."""
def test_returns_nothing_without_a_sync_client(self) -> None:
with patch("screen_locker._workout_sync.sync_client", return_value=None):
assert pull_all_session_records() == []
def test_returns_the_merged_records(self) -> None:
merged = {"r1": (_session(), object())}
with (
patch("screen_locker._workout_sync.sync_client", return_value=object()),
patch(
"screen_locker._workout_sync._merge_device_records",
return_value=merged,
),
):
assert pull_all_session_records() == [("r1", _session())]