From 870a65015fc1d6c90b7d50d64bc4f29363120a0d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 11:15:07 +0200 Subject: [PATCH] Manual-workout phone sync P2: PC ingestion (multi-record + manual branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingest manual/off-app workouts synced from the phone (or another device) into workout_log.json so they count toward the weekly-minimum unlock. No phone UI yet — fully driven by a crafted sync log. - _workout_sync.py: pull_all_manual_records() merges every device log under the sync prefix (phone, pc, …), keeping the highest-HLC copy per stable record id; and the session-verify path (_latest_payload) now skips kind="manual_workout" records so a manual can never be treated as a StrongLifts session. - _manual_sync.py (new): reconstruct_draft() rebuilds a ManualWorkoutDraft from a synced payload (raw inputs only — never trusting the phone's derived fields); ingest_manual_records() re-validates with the same validate_manual_workout the local form uses, enforces the rate budget, then HMAC-signs and writes each under its own date. Idempotent (dedup by sync_record_id) and non-destructive (never clobbers an existing counted day). - _log_mixin.py: extract write_signed_entry(log_file, date, workout_data) so the live save path and ingestion share one date-keyed signer; _load_existing_logs now delegates to the shared _read_logs. - screen_lock.py: run _ingest_synced_manual_workouts() at startup, beside the RunnerUp auto-fill, before the lock-decision early-exits. Trust: a phone can at most add a budget-limited honour-system manual_workout, never a phone_verified one; the PC re-validates and signs on ingest. Full suite green at 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH --- screen_locker/_log_mixin.py | 65 +++--- screen_locker/_manual_sync.py | 162 ++++++++++++++ screen_locker/_workout_sync.py | 89 +++++++- screen_locker/screen_lock.py | 12 ++ screen_locker/tests/test_manual_sync.py | 204 ++++++++++++++++++ .../tests/test_screen_lock_coverage_part2.py | 37 ++++ screen_locker/tests/test_workout_sync.py | 174 +++++++++++++++ 7 files changed, 716 insertions(+), 27 deletions(-) create mode 100644 screen_locker/_manual_sync.py create mode 100644 screen_locker/tests/test_manual_sync.py diff --git a/screen_locker/_log_mixin.py b/screen_locker/_log_mixin.py index 91c93d4..2588bc9 100644 --- a/screen_locker/_log_mixin.py +++ b/screen_locker/_log_mixin.py @@ -19,6 +19,44 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) +def _read_logs(log_file: Path) -> dict: + """Load ``workout_log.json`` as a dict, or ``{}`` if missing/corrupt.""" + if not log_file.exists(): + return {} + try: + with log_file.open() as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +def write_signed_entry( + log_file: Path, date: str, workout_data: Mapping[str, object] +) -> None: + """Write one HMAC-signed workout entry into ``log_file`` keyed by ``date``. + + Shared by the live save path (today's workout) and manual-sync ingestion, + which files a synced workout under its OWN date so the weekly count places + it in the right ISO week. The log is day-keyed: one entry per date. + """ + logs = _read_logs(log_file) + entry: dict[str, object] = { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "workout_data": workout_data, + } + signature = compute_entry_hmac(entry) + if signature is not None: + entry["hmac"] = signature + else: + _logger.warning("HMAC key unavailable — saving unsigned entry") + logs[date] = entry + try: + with log_file.open("w") as f: + json.dump(logs, f, indent=2) + except OSError as e: + _logger.warning("Could not save workout log: %s", e) + + class LogMixin: """Handles reading and writing workout_log.json for the ScreenLocker. @@ -37,34 +75,13 @@ class LogMixin: def _load_existing_logs(self) -> dict: """Load existing workout logs from file.""" - if not self.log_file.exists(): - return {} - try: - with self.log_file.open() as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return {} + return _read_logs(self.log_file) def _is_scheduled_skip_today(self) -> bool: """Return True if today's date is listed in the scheduled skips file.""" return _compliance_state.is_scheduled_skip_today(SCHEDULED_SKIPS_FILE) def save_workout_log(self) -> None: - """Save workout data to log file with HMAC signature.""" - logs = self._load_existing_logs() + """Save today's workout data to the log file with an HMAC signature.""" today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - entry: dict[str, object] = { - "timestamp": datetime.now(tz=timezone.utc).isoformat(), - "workout_data": self.workout_data, - } - signature = compute_entry_hmac(entry) - if signature is not None: - entry["hmac"] = signature - else: - _logger.warning("HMAC key unavailable — saving unsigned entry") - logs[today] = entry - try: - with self.log_file.open("w") as f: - json.dump(logs, f, indent=2) - except OSError as e: - _logger.warning("Could not save workout log: %s", e) + write_signed_entry(self.log_file, today, self.workout_data) diff --git a/screen_locker/_manual_sync.py b/screen_locker/_manual_sync.py new file mode 100644 index 0000000..ec5c796 --- /dev/null +++ b/screen_locker/_manual_sync.py @@ -0,0 +1,162 @@ +"""Ingest phone-/PC-synced manual workouts into ``workout_log.json``. + +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. + +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 +form uses, re-built with :func:`build_entry`, and only then HMAC-signed by the +PC. A phone can therefore at most add a budget-limited, honour-system +``manual_workout`` — never a ``phone_verified`` one (the session path skips +manual records, see :mod:`screen_locker._workout_sync`). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from screen_locker._log_mixin import _read_logs, write_signed_entry +from screen_locker._manual_workout import ( + MANUAL_WORKOUT_SYNC_KIND, + SPORT_OTHER, + ManualWorkoutDraft, + build_entry, + is_budget_exhausted, + validate_manual_workout, +) +from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from pathlib import Path + +_logger = logging.getLogger(__name__) + +# Key under which the source sync record id is stored in the ingested +# ``workout_data`` — makes re-ingestion of the same record idempotent. +_SYNC_ID_FIELD = "sync_record_id" + + +def _coerce_int(value: object) -> int: + """Coerce a JSON scalar to int; raise for a non-numeric (skips the record).""" + if isinstance(value, (int, str)): + return int(value) + raise TypeError(value) + + +def reconstruct_draft(payload: Mapping[str, object]) -> ManualWorkoutDraft | None: + """Rebuild a :class:`ManualWorkoutDraft` from a synced manual payload. + + Returns None if a required raw field is missing or mistyped, so a malformed + record is skipped rather than crashing ingestion. Only the raw user inputs + are read back — the derived fields (``source``, ``duration_minutes``, + ``type``) are recomputed by :func:`build_entry` on the PC. + """ + try: + sport = str(payload["sport"]) + activity_type_other = ( + str(payload.get("activity_type", "")) if sport == SPORT_OTHER else "" + ) + return ManualWorkoutDraft( + sport=sport, + start_time=str(payload["start_time"]), + end_time=str(payload["end_time"]), + location_name=str(payload["location_name"]), + transport_method=str(payload["transport_method"]), + cost=str(payload["cost"]), + rpe=_coerce_int(payload["rpe"]), + went_well=str(payload["went_well"]), + to_improve=str(payload["to_improve"]), + overall_feeling=str(payload["overall_feeling"]), + reservation_phone=str(payload.get("reservation_phone", "")), + techniques_practiced=str(payload.get("techniques_practiced", "")), + warm_up_minutes=str(payload.get("warm_up_minutes", "")), + pain_or_injury=str(payload.get("pain_or_injury", "none")), + matches_won=_coerce_int(payload.get("matches_won", 0)), + matches_lost=_coerce_int(payload.get("matches_lost", 0)), + sets_won=_coerce_int(payload.get("sets_won", 0)), + sets_lost=_coerce_int(payload.get("sets_lost", 0)), + racket=str(payload.get("racket", "")), + balls=str(payload.get("balls", "")), + activity_type_other=activity_type_other, + activity_details=str(payload.get("activity_details", "")), + equipment=str(payload.get("equipment", "")), + ) + except (KeyError, TypeError, ValueError): + return None + + +def _already_ingested(logs: dict, record_id: str) -> bool: + """True if a log entry already carries this sync record id.""" + for entry in logs.values(): + if not isinstance(entry, dict): + continue + workout_data = entry.get("workout_data", {}) + if ( + isinstance(workout_data, dict) + and workout_data.get(_SYNC_ID_FIELD) == record_id + ): + return True + return False + + +def _date_has_counted_workout(logs: dict, date: str) -> bool: + """True if ``date`` already holds a workout that counts (day-keyed log).""" + entry = logs.get(date) + if not isinstance(entry, dict): + return False + workout_data = entry.get("workout_data", {}) + return ( + isinstance(workout_data, dict) + and workout_data.get("type") in COUNTED_WORKOUT_TYPES + ) + + +def ingest_manual_records( + log_file: Path, + records: Iterable[tuple[str, Mapping[str, object]]], + *, + today: str | None = None, +) -> list[str]: + """Ingest synced manual workouts into ``workout_log.json``. + + For each ``(record_id, payload)`` tagged ``kind="manual_workout"``: + reconstruct + re-validate the draft on the PC, enforce the rate budget, then + HMAC-sign and write it under its own ``date``. Idempotent (dedup by + ``record_id``) and non-destructive: it never overwrites an existing counted + entry for a day (the log is day-keyed — a real workout already credits it). + Returns the record ids actually ingested. + """ + ingested: list[str] = [] + for record_id, payload in records: + if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND: + continue + date = payload.get("date") + if not isinstance(date, str): + _logger.warning("Manual record %s has no date — skipped", record_id) + continue + logs = _read_logs(log_file) + if _already_ingested(logs, record_id): + continue + if _date_has_counted_workout(logs, date): + continue + draft = reconstruct_draft(payload) + if draft is None: + _logger.warning("Manual record %s is malformed — skipped", record_id) + continue + error = validate_manual_workout(draft) + if error is not None: + _logger.warning("Manual record %s invalid: %s", record_id, error) + continue + if is_budget_exhausted(log_file, today=today): + _logger.info("Manual-workout budget exhausted — %s not ingested", record_id) + continue + entry = build_entry(draft) + entry[_SYNC_ID_FIELD] = record_id + write_signed_entry(log_file, date, entry) + ingested.append(record_id) + return ingested diff --git a/screen_locker/_workout_sync.py b/screen_locker/_workout_sync.py index d5aafef..3309d6d 100644 --- a/screen_locker/_workout_sync.py +++ b/screen_locker/_workout_sync.py @@ -13,7 +13,7 @@ from __future__ import annotations import json import logging -from crdt_sync import GitHubSyncClient, GitHubSyncError, Record +from crdt_sync import GitHubSyncClient, GitHubSyncError, Hlc, Record from screen_locker._constants import ( SYNC_PHONE_DEVICE_ID, @@ -22,13 +22,20 @@ from screen_locker._constants import ( SYNC_TIMEOUT_SECONDS, SYNC_TOKEN_FILE, ) +from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND _logger = logging.getLogger(__name__) -_LOG_PATH = f"screen-locker-sync/devices/{SYNC_PHONE_DEVICE_ID}/log.json" +_DEVICES_PREFIX = "screen-locker-sync/devices" +_LOG_PATH = f"{_DEVICES_PREFIX}/{SYNC_PHONE_DEVICE_ID}/log.json" _PAYLOAD_FIELD = "payload" +def _is_manual_payload(payload: object) -> bool: + """True if a decoded sync payload is a manual-workout record.""" + return isinstance(payload, dict) and payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND + + def read_sync_token() -> str | None: """Return the saved sync PAT, or None if sync isn't configured. @@ -59,7 +66,12 @@ def _latest_payload(log_json: str) -> dict | None: raise TypeError(msg) records = [Record.from_dict(data) for data in raw.values()] - candidates = [record for record in records if _PAYLOAD_FIELD in record.fields] + candidates = [ + record + for record in records + if _PAYLOAD_FIELD in record.fields + and not _is_manual_payload(record.fields[_PAYLOAD_FIELD][0]) + ] if not candidates: return None latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1]) @@ -106,3 +118,74 @@ def pull_synced_workout() -> tuple[dict | None, str | None]: return None, f"corrupt sync data: {exc}" return payload, None + + +def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]: + """Return ``{record_id: (payload, hlc)}`` for manual records in a log blob. + + Raises the same decode errors as :func:`_latest_payload`; callers treat a + corrupt device log as "no manual records from that device". + """ + raw = json.loads(log_json) + if not isinstance(raw, dict): + msg = f"top-level sync payload is not a JSON object: {raw!r}" + raise TypeError(msg) + result: dict[str, tuple[dict, Hlc]] = {} + for data in raw.values(): + record = Record.from_dict(data) + field = record.fields.get(_PAYLOAD_FIELD) + if field is None: + continue + payload, hlc = field + if _is_manual_payload(payload): + result[record.id] = (payload, hlc) + return result + + +def pull_all_manual_records() -> list[tuple[str, dict]]: + """Return all synced manual-workout records across every device log. + + Merges every ``devices//log.json`` under the sync prefix (phone, + pc, …), keeping the highest-HLC copy of each record id — records are + id-stable, so the same workout mirrored into two device logs dedups to one. + Best-effort: an unconfigured token, an unreachable repo, or a corrupt device + log yields fewer/no records rather than raising — manual sync, like session + sync, is optional. + """ + token = read_sync_token() + if token is None: + return [] + + client = GitHubSyncClient( + SYNC_REPO_OWNER, + SYNC_REPO_NAME, + token, + timeout_seconds=SYNC_TIMEOUT_SECONDS, + ) + try: + devices = client.list_directory(_DEVICES_PREFIX) + except GitHubSyncError as exc: + _logger.info("Manual sync unavailable (%s)", exc) + return [] + + merged: dict[str, tuple[dict, Hlc]] = {} + for device in devices: + path = f"{_DEVICES_PREFIX}/{device}/log.json" + try: + text = client.get_file_text(path) + except GitHubSyncError as exc: + _logger.info("Skipping device log %s (%s)", path, exc) + continue + if text is None: + continue + try: + records = _manual_records(text) + except (ValueError, KeyError, TypeError) as exc: + _logger.warning("Corrupt sync data at %s: %s", path, exc) + continue + for rid, (payload, hlc) in records.items(): + existing = merged.get(rid) + if existing is None or existing[1] < hlc: + merged[rid] = (payload, hlc) + + return [(rid, payload) for rid, (payload, _hlc) in merged.items()] diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 2b0bc03..954ec21 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -40,6 +40,7 @@ from screen_locker._extra_benefits import ( ) from screen_locker._heat_skip import HeatSkipMixin from screen_locker._log_mixin import LogMixin +from screen_locker._manual_sync import ingest_manual_records from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._runnerup_verification import RunnerUpVerificationMixin @@ -58,6 +59,7 @@ from screen_locker._weekly_check import ( ) from screen_locker._window_setup import WindowSetupMixin from screen_locker._workout_credit import WorkoutCreditMixin +from screen_locker._workout_sync import pull_all_manual_records if TYPE_CHECKING: from collections.abc import Callable @@ -184,6 +186,10 @@ class ScreenLocker( SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE ): self._apply_weekly_shutdown_bonus() + # Ingest any manual workouts synced from the phone (or another device) + # before the early-exit checks, so a manual logged off-app still counts + # toward today's/this week's minimum. + self._ingest_synced_manual_workouts() # Auto-fill any RunnerUp workouts from earlier in the current ISO week # before any early-exit check, so gaps are closed regardless of today's # logged state (early_bird, sick_day, etc.). @@ -210,6 +216,12 @@ class ScreenLocker( # shutdown-time-only reward (see _apply_weekly_shutdown_bonus). self._check_heat_skip_exit() + def _ingest_synced_manual_workouts(self) -> None: + """Log any manual workouts synced from the phone or another device.""" + ingested = ingest_manual_records(self.log_file, pull_all_manual_records()) + for record_id in ingested: + _logger.info("Ingested synced manual workout: %s", record_id) + def _auto_fill_week_runnerup_bonus(self) -> None: """Auto-fill missed RunnerUp workouts and award any earned bonus.""" prev_count = count_weekly_workouts(self.log_file) diff --git a/screen_locker/tests/test_manual_sync.py b/screen_locker/tests/test_manual_sync.py new file mode 100644 index 0000000..4d4944b --- /dev/null +++ b/screen_locker/tests/test_manual_sync.py @@ -0,0 +1,204 @@ +"""Tests for ingesting synced manual workouts into workout_log.json.""" +# pylint: disable=protected-access + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker import _manual_sync +from screen_locker._manual_sync import ( + ingest_manual_records, + reconstruct_draft, +) +from screen_locker._manual_workout import ( + SPORT_OTHER, + SPORT_TABLE_TENNIS, + ManualWorkoutDraft, + build_sync_payload, + manual_sync_record_id, +) +from screen_locker._weekly_check import count_weekly_workouts + +if TYPE_CHECKING: + from pathlib import Path + +_DATE = "2026-07-13" + +_TT_DRAFT = ManualWorkoutDraft( + sport=SPORT_TABLE_TENNIS, + start_time="18:00", + end_time="19:30", + location_name="Solec", + transport_method="bike", + cost="40 PLN", + rpe=6, + went_well="Served consistently and moved feet well", + to_improve="Backhand topspin needs more consistency", + overall_feeling="Felt strong focused and in good rhythm", + matches_won=3, + matches_lost=1, + sets_won=7, + sets_lost=4, + racket="Butterfly", + balls="Nittaku 3-star", +) +_OTHER_DRAFT = ManualWorkoutDraft( + sport=SPORT_OTHER, + start_time="09:00", + end_time="10:00", + location_name="Squash club", + transport_method="car", + cost="30 PLN", + rpe=5, + went_well="Moved well and kept long rallies going", + to_improve="Need to volley earlier off the back wall", + overall_feeling="Tired but satisfied with the effort", + activity_type_other="squash", + activity_details="Full-court squash drills and three practice games total", + equipment="racket, goggles", +) + + +def _manual_record(draft: ManualWorkoutDraft, date: str = _DATE) -> tuple[str, dict]: + """Build a (record_id, payload) pair as it would arrive from sync.""" + payload = build_sync_payload(draft, date) + return manual_sync_record_id(date, draft.start_time), payload + + +class TestReconstructDraft: + def test_rebuilds_a_table_tennis_draft(self) -> None: + _, payload = _manual_record(_TT_DRAFT) + draft = reconstruct_draft(payload) + assert draft is not None + assert draft.sport == SPORT_TABLE_TENNIS + assert draft.racket == "Butterfly" + + def test_rebuilds_an_other_sport_draft(self) -> None: + _, payload = _manual_record(_OTHER_DRAFT) + draft = reconstruct_draft(payload) + assert draft is not None + assert draft.activity_type_other == "squash" + + def test_returns_none_when_a_required_field_is_missing(self) -> None: + _, payload = _manual_record(_TT_DRAFT) + del payload["start_time"] + assert reconstruct_draft(payload) is None + + def test_returns_none_on_a_mistyped_field(self) -> None: + _, payload = _manual_record(_TT_DRAFT) + payload["rpe"] = None + assert reconstruct_draft(payload) is None + + +class TestIngestManualRecords: + def test_ingests_a_valid_record(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + record = _manual_record(_TT_DRAFT) + ingested = ingest_manual_records(log_file, [record], today=_DATE) + assert ingested == [record[0]] + stored = json.loads(log_file.read_text()) + workout = stored[_DATE]["workout_data"] + assert workout["type"] == "manual_workout" + assert workout["sync_record_id"] == record[0] + assert count_weekly_workouts(log_file, today=_dt(_DATE)) == 1 + + def test_is_idempotent(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + record = _manual_record(_TT_DRAFT) + ingest_manual_records(log_file, [record], today=_DATE) + again = ingest_manual_records(log_file, [record], today=_DATE) + assert again == [] + + def test_skips_a_non_manual_payload(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + assert ( + ingest_manual_records(log_file, [("s", {"succeeded": True})], today=_DATE) + == [] + ) + + def test_skips_a_record_without_a_date(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _, payload = _manual_record(_TT_DRAFT) + del payload["date"] + assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == [] + + def test_does_not_clobber_an_existing_counted_day(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) + assert ingest_manual_records(log_file, [record], today=_DATE) == [] + # The original phone_verified entry is untouched. + stored = json.loads(log_file.read_text()) + assert stored[_DATE]["workout_data"]["type"] == "phone_verified" + + def test_skips_a_malformed_payload(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _, payload = _manual_record(_TT_DRAFT) + payload["rpe"] = None # reconstruct_draft -> None + assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == [] + + def test_skips_an_invalid_draft(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _, payload = _manual_record(_TT_DRAFT) + payload["location_name"] = " " # fails validate_manual_workout + assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == [] + + def test_skips_when_budget_exhausted(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + record = _manual_record(_TT_DRAFT) + with patch.object(_manual_sync, "is_budget_exhausted", return_value=True): + assert ingest_manual_records(log_file, [record], today=_DATE) == [] + + def test_writes_unsigned_entry_when_hmac_key_unavailable( + self, tmp_path: Path + ) -> None: + log_file = tmp_path / "workout_log.json" + record = _manual_record(_TT_DRAFT) + with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None): + ingest_manual_records(log_file, [record], today=_DATE) + stored = json.loads(log_file.read_text()) + assert "hmac" not in stored[_DATE] + + +class TestIngestHelpers: + def test_already_ingested_ignores_non_dict_entries(self) -> None: + logs = {"a": "not-a-dict", "b": {"workout_data": {"sync_record_id": "x"}}} + assert _manual_sync._already_ingested(logs, "x") is True + assert _manual_sync._already_ingested(logs, "y") is False + + def test_date_has_counted_workout_variants(self) -> None: + logs = { + "d1": {"workout_data": {"type": "manual_workout"}}, + "d2": {"workout_data": {"type": "nope"}}, + "d3": "not-a-dict", + } + assert _manual_sync._date_has_counted_workout(logs, "d1") is True + assert _manual_sync._date_has_counted_workout(logs, "d2") is False + assert _manual_sync._date_has_counted_workout(logs, "d3") is False + assert _manual_sync._date_has_counted_workout(logs, "missing") is False + + +class TestReadLogs: + def test_missing_file_returns_empty(self, tmp_path: Path) -> None: + from screen_locker._log_mixin import _read_logs + + assert _read_logs(tmp_path / "nope.json") == {} + + def test_corrupt_file_returns_empty(self, tmp_path: Path) -> None: + from screen_locker._log_mixin import _read_logs + + log_file = tmp_path / "workout_log.json" + log_file.write_text("{not valid json") + assert _read_logs(log_file) == {} + + +def _dt(date: str) -> object: + from datetime import datetime, timezone + + return datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc) diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index 7379e76..24db878 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -180,3 +180,40 @@ class TestUnlockScreenExtras: patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), ): locker.unlock_screen() + + +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: + locker = create_locker(mock_tk, tmp_path) + with ( + patch( + "screen_locker.screen_lock.pull_all_manual_records", + return_value=[("manual:x", {})], + ), + patch( + "screen_locker.screen_lock.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: + locker = create_locker(mock_tk, tmp_path) + with ( + patch( + "screen_locker.screen_lock.pull_all_manual_records", + return_value=[], + ), + patch( + "screen_locker.screen_lock.ingest_manual_records", + return_value=[], + ), + ): + locker._ingest_synced_manual_workouts() diff --git a/screen_locker/tests/test_workout_sync.py b/screen_locker/tests/test_workout_sync.py index 1608d14..3129291 100644 --- a/screen_locker/tests/test_workout_sync.py +++ b/screen_locker/tests/test_workout_sync.py @@ -118,3 +118,177 @@ class TestPullSyncedWorkout: assert data is None assert error is not None assert "corrupt sync data" in error + + +def _manual_payload(**extra: object) -> dict: + payload = {"kind": "manual_workout", "date": "2026-07-13"} + payload.update(extra) + return payload + + +def _manual_record_dict( + record_id: str, payload: dict, *, wall_time_ms: int = 1000 +) -> dict: + hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone") + return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict() + + +class TestLatestPayloadSkipsManual: + def test_manual_only_log_yields_no_session(self) -> None: + log = {"m": _manual_record_dict("manual:1", _manual_payload())} + assert _workout_sync._latest_payload(json.dumps(log)) is None + + def test_returns_session_even_when_a_later_manual_exists(self) -> None: + log = { + "s": _record_json({"succeeded": True}, wall_time_ms=100), + "m": _manual_record_dict("manual:1", _manual_payload(), wall_time_ms=999), + } + assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True} + + +class TestIsManualPayload: + def test_true_for_manual_kind(self) -> None: + assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True + + def test_false_for_session_or_non_dict(self) -> None: + assert _workout_sync._is_manual_payload({"succeeded": True}) is False + assert _workout_sync._is_manual_payload("not-a-dict") is False + + +class TestManualRecords: + def test_returns_only_manual_records(self) -> None: + log = { + "s": _record_json({"succeeded": True}), + "m": _manual_record_dict("manual:1", _manual_payload()), + } + result = _workout_sync._manual_records(json.dumps(log)) + assert list(result) == ["manual:1"] + + def test_skips_records_without_a_payload_field(self) -> None: + no_payload = Record( + id="a", + fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))}, + ) + log = {"a": no_payload.to_dict()} + assert _workout_sync._manual_records(json.dumps(log)) == {} + + def test_raises_type_error_when_top_level_is_not_an_object(self) -> None: + with pytest.raises(TypeError, match="not a JSON object"): + _workout_sync._manual_records(json.dumps([1, 2, 3])) + + +def _multi_device_client(device_logs: dict[str, object]) -> MagicMock: + client = MagicMock() + client.list_directory.return_value = list(device_logs) + + def _get(path: str) -> object: + device = path.split("/")[-2] + value = device_logs[device] + if isinstance(value, Exception): + raise value + return value + + client.get_file_text.side_effect = _get + return client + + +class TestPullAllManualRecords: + def test_returns_empty_when_no_token(self) -> None: + assert _workout_sync.pull_all_manual_records() == [] + + def test_returns_empty_when_listing_fails(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = MagicMock() + client.list_directory.side_effect = GitHubSyncError("offline") + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_all_manual_records() == [] + + def test_merges_manual_records_across_devices(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + {"a": _manual_record_dict("manual:a", _manual_payload())} + ), + "pc": json.dumps( + {"b": _manual_record_dict("manual:b", _manual_payload())} + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"] + + def test_skips_missing_and_corrupt_device_logs(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + {"a": _manual_record_dict("manual:a", _manual_payload())} + ), + "gone": GitHubSyncError("404"), + "empty": None, + "corrupt": "{not json", + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert [rid for rid, _ in result] == ["manual:a"] + + def test_dedups_same_id_keeping_highest_clock(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="OLD"), + wall_time_ms=100, + ) + } + ), + "pc": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="NEW"), + wall_time_ms=200, + ) + } + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert len(result) == 1 + assert result[0][1]["cost"] == "NEW" + + def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="NEW"), + wall_time_ms=200, + ) + } + ), + "pc": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="OLD"), + wall_time_ms=100, + ) + } + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert len(result) == 1 + assert result[0][1]["cost"] == "NEW"