mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
`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
199 lines
7.6 KiB
Python
199 lines
7.6 KiB
Python
"""Tests for ingesting manual workouts synced from the phone.
|
|
|
|
Split out of test_screen_lock_coverage_part2.py for the 250-line cap.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from screen_locker.tests.conftest import create_locker
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
class TestIngestSyncedManualWorkouts:
|
|
"""Tests for _ingest_synced_manual_workouts (manual-sync wiring)."""
|
|
|
|
def test_logs_each_ingested_record(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
"""Logs each ingested record."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with (
|
|
patch(
|
|
"screen_locker._sync_mixin.pull_all_manual_records",
|
|
return_value=[("manual:x", {})],
|
|
),
|
|
patch(
|
|
"screen_locker._sync_mixin.ingest_manual_records",
|
|
return_value=["manual:x"],
|
|
) as ingest,
|
|
):
|
|
locker._ingest_synced_manual_workouts()
|
|
ingest.assert_called_once()
|
|
|
|
def test_no_records_ingests_nothing(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
"""No records ingests nothing."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with (
|
|
patch(
|
|
"screen_locker._sync_mixin.pull_all_manual_records",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"screen_locker._sync_mixin.ingest_manual_records",
|
|
return_value=[],
|
|
),
|
|
):
|
|
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._sync_mixin.pull_all_manual_records",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"screen_locker._sync_mixin.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)
|
|
|
|
|
|
class TestIngestSyncedSessions:
|
|
"""Tests for _ingest_synced_sessions (StrongLifts-session wiring).
|
|
|
|
The headless ``--sync-only`` path had no session ingest at all, so a
|
|
workout finished on the PC earned nothing until a lock screen opened.
|
|
"""
|
|
|
|
def test_logs_each_ingested_session(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
"""Logs each ingested session."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with (
|
|
patch(
|
|
"screen_locker._sync_mixin.pull_all_session_records",
|
|
return_value=[("2026-08-21T10:39:24", {})],
|
|
),
|
|
patch(
|
|
"screen_locker._sync_mixin.ingest_session_records",
|
|
return_value=["2026-08-21T10:39:24"],
|
|
) as ingest,
|
|
):
|
|
locker._ingest_synced_sessions()
|
|
ingest.assert_called_once()
|
|
|
|
def test_no_sessions_ingests_nothing(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
"""No sessions ingests nothing."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with (
|
|
patch(
|
|
"screen_locker._sync_mixin.pull_all_session_records",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"screen_locker._sync_mixin.ingest_session_records",
|
|
return_value=[],
|
|
),
|
|
):
|
|
locker._ingest_synced_sessions()
|
|
|
|
def test_credits_a_session_like_a_live_workout(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
"""A synced session earns the same reward a live workout would."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with (
|
|
patch(
|
|
"screen_locker._sync_mixin.pull_all_session_records",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"screen_locker._sync_mixin.ingest_session_records",
|
|
return_value=[],
|
|
) as ingest,
|
|
):
|
|
locker._ingest_synced_sessions()
|
|
assert (
|
|
ingest.call_args.kwargs["on_ingested"]
|
|
== locker._credit_ingested_manual_workout
|
|
)
|
|
assert locker.workout_data == {}
|