From d7ac74a5e12ee226b5fe402dd94adb717f882a88 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 16 Jun 2026 19:23:32 +0200 Subject: [PATCH] feat: stuff --- src/garmin_coach_clone/config.py | 2 + src/garmin_coach_clone/sync_service.py | 222 ++++++++++++++++++---- src/garmin_coach_clone/workouts.py | 36 +++- tests/test_sync_service.py | 244 ++++++++++++++++++++++++- tests/test_workouts.py | 21 +++ 5 files changed, 490 insertions(+), 35 deletions(-) diff --git a/src/garmin_coach_clone/config.py b/src/garmin_coach_clone/config.py index 8668cff..f7f7911 100644 --- a/src/garmin_coach_clone/config.py +++ b/src/garmin_coach_clone/config.py @@ -41,6 +41,7 @@ class Settings: sync_days_ahead: int overwrite_existing: bool delete_old_clones: bool + clone_retention_days: int change_interval_minutes: int change_active_window: str change_fixed_times: list[str] @@ -75,6 +76,7 @@ def load_settings() -> Settings: sync_days_ahead=max(0, int(os.getenv("SYNC_DAYS_AHEAD", "1"))), overwrite_existing=_bool_env("OVERWRITE_EXISTING", True), delete_old_clones=_bool_env("DELETE_OLD_CLONES", False), + clone_retention_days=max(0, int(os.getenv("CLONE_RETENTION_DAYS", "5"))), change_interval_minutes=max( 5, int(os.getenv("CHANGE_DETECTION_INTERVAL_MINUTES", str(DEFAULT_INTERVAL_MINUTES))) ), diff --git a/src/garmin_coach_clone/sync_service.py b/src/garmin_coach_clone/sync_service.py index 15c8a28..6792a3e 100644 --- a/src/garmin_coach_clone/sync_service.py +++ b/src/garmin_coach_clone/sync_service.py @@ -10,10 +10,14 @@ from .config import Settings from .io import redact from .repository import Repository, ScheduleConfig from .workouts import ( + calendar_entry_date, calendar_entry_id, + calendar_entry_name, clone_workout_payload, - existing_clone_names, + existing_clone_workouts, generated_calendar_entries, + generated_clone_date, + generated_workouts_older_than, summarize_workout, validate_workout_payload, workout_source_hash, @@ -108,6 +112,9 @@ class SyncService: for target_date in dates: decision = self._sync_date(run_id, client, detail, target_date, dry_run) counts[decision] = counts.get(decision, 0) + 1 + if not dry_run: + cleanup = self._cleanup_old_clones(run_id, client) + counts["warnings"] += cleanup["warnings"] self.repo.finish_sync_run(run_id, status, counts) return {"run_id": run_id, "status": status, **counts} except Exception as exc: @@ -165,35 +172,69 @@ class SyncService: source_hash=source_hash, ) + stale_scheduled_ids: list[str] = [] if ( mapping and mapping.get("source_hash") == source_hash and mapping.get("clone_workout_id") ): - return self._trace( - run_id, - date_s, - source, - payload, - "skip_unchanged", - "skipped", - "Generated clone is current", - source_hash=source_hash, + clone_name = str(mapping.get("clone_workout_name") or payload["workoutName"]) + current_workout = self._find_current_clone_workout( + client.get_workouts(limit=100), mapping, clone_name ) + scheduled_ids = self._find_scheduled_ids(client, clone_name, target_date) + if current_workout is not None and scheduled_ids: + removed = self._repair_current_schedule( + client, + mapping, + payload, + target_date, + current_workout=current_workout, + scheduled_ids=scheduled_ids, + ) + if removed: + return self._trace( + run_id, + date_s, + source, + payload, + "dedupe_unchanged", + "completed", + "Removed duplicate generated calendar entries: " + ", ".join(removed), + source_hash=source_hash, + ) + return self._trace( + run_id, + date_s, + source, + payload, + "skip_unchanged", + "skipped", + "Generated clone is current", + source_hash=source_hash, + ) + stale_scheduled_ids = scheduled_ids + action = "recreate_missing" + else: + action = "create" if mapping is None else "replace_changed" - existing = existing_clone_names( + existing = existing_clone_workouts( client.get_workouts(limit=100), target_date, self.settings.clone_prefix ) if not mapping and existing: + existing_workout = existing[0] + existing_name = str(existing_workout.get("workoutName") or payload["workoutName"]) + existing_id = existing_workout.get("workoutId") or existing_workout.get("id") + existing_scheduled_ids = self._find_scheduled_ids(client, existing_name, target_date) self.repo.upsert_clone_mapping( { "scheduled_date": date_s, "source_uuid": source_uuid, "source_hash": source_hash, "source_name": source_name, - "clone_workout_id": None, - "clone_workout_name": existing[0], - "scheduled_workout_id": None, + "clone_workout_id": None if existing_id is None else str(existing_id), + "clone_workout_name": existing_name, + "scheduled_workout_id": _last_or_none(existing_scheduled_ids), "status": "external_existing", "message": "Generated clone exists in Garmin but is not mapped locally", } @@ -209,7 +250,6 @@ class SyncService: source_hash=source_hash, ) - action = "create" if mapping is None else "replace_changed" if dry_run: return self._trace( run_id, @@ -222,16 +262,19 @@ class SyncService: source_hash=source_hash, ) - if mapping and mapping.get("clone_workout_id"): - self._replace_existing(client, mapping) + if mapping and action != "recreate_missing": + self._replace_existing(client, mapping, target_date) + elif action == "recreate_missing": + for scheduled_id in stale_scheduled_ids: + client.unschedule_workout(scheduled_id) upload_result = client.upload_workout(payload) workout_id = upload_result.get("workoutId") or upload_result.get("id") if workout_id is None: raise ValueError(f"Upload returned no workoutId: {upload_result}") schedule_result = client.schedule_workout(workout_id, date_s) - scheduled_id = _scheduled_id(schedule_result) or self._find_scheduled_id( - client, payload, target_date + scheduled_id = _scheduled_id(schedule_result) or _last_or_none( + self._find_scheduled_ids(client, str(payload["workoutName"]), target_date) ) self.repo.upsert_clone_mapping( { @@ -263,24 +306,124 @@ class SyncService: return "created" return status_key - def _replace_existing(self, client: Any, mapping: dict[str, Any]) -> None: - scheduled_id = mapping.get("scheduled_workout_id") - if scheduled_id: + def _repair_current_schedule( + self, + client: Any, + mapping: dict[str, Any], + payload: dict[str, Any], + target_date: date, + current_workout: dict[str, Any] | None = None, + scheduled_ids: list[str] | None = None, + ) -> list[str]: + clone_name = str(mapping.get("clone_workout_name") or payload["workoutName"]) + scheduled_ids = scheduled_ids or self._find_scheduled_ids(client, clone_name, target_date) + if not scheduled_ids: + return [] + + keep = str(mapping.get("scheduled_workout_id") or scheduled_ids[-1]) + if keep not in scheduled_ids: + keep = scheduled_ids[-1] + + updated = {**mapping} + current_id = _workout_id(current_workout) + if current_id is not None: + updated["clone_workout_id"] = current_id + if mapping.get("scheduled_workout_id") != keep: + updated["scheduled_workout_id"] = keep + if updated != mapping: + self.repo.upsert_clone_mapping(updated) + + duplicates = [scheduled_id for scheduled_id in scheduled_ids if scheduled_id != keep] + for scheduled_id in duplicates: + client.unschedule_workout(scheduled_id) + return duplicates + + def _find_current_clone_workout( + self, workouts: list[dict[str, Any]], mapping: dict[str, Any], clone_name: str + ) -> dict[str, Any] | None: + mapped_id = mapping.get("clone_workout_id") + for workout in workouts: + workout_id = _workout_id(workout) + workout_name = str(workout.get("workoutName") or "") + if mapped_id and workout_id == str(mapped_id): + return workout + if workout_name == clone_name: + return workout + return None + + def _replace_existing( + self, client: Any, mapping: dict[str, Any], target_date: date + ) -> None: + scheduled_ids: set[str] = set() + if mapping.get("scheduled_workout_id"): + scheduled_ids.add(str(mapping["scheduled_workout_id"])) + clone_name = mapping.get("clone_workout_name") + if clone_name: + scheduled_ids.update(self._find_scheduled_ids(client, str(clone_name), target_date)) + + for scheduled_id in sorted(scheduled_ids): client.unschedule_workout(str(scheduled_id)) if self.settings.delete_old_clones and mapping.get("clone_workout_id"): client.delete_workout(str(mapping["clone_workout_id"])) - def _find_scheduled_id( - self, client: Any, payload: dict[str, Any], target_date: date - ) -> str | None: + def _find_scheduled_ids( + self, client: Any, clone_name: str, target_date: date + ) -> list[str]: + date_s = target_date.isoformat() calendar = client.get_scheduled_workouts(target_date.year, target_date.month) + ids: list[str] = [] for entry in generated_calendar_entries(calendar, self.settings.clone_prefix): - if ( - entry.get("workoutName") == payload["workoutName"] - or entry.get("title") == payload["workoutName"] - ): - return calendar_entry_id(entry) - return None + entry_date = calendar_entry_date(entry) + if entry_date is not None and entry_date != date_s: + continue + if calendar_entry_name(entry) == clone_name and calendar_entry_id(entry) is not None: + ids.append(str(calendar_entry_id(entry))) + return ids + + def _cleanup_old_clones(self, run_id: int, client: Any) -> dict[str, int]: + retention_days = self.settings.clone_retention_days + cutoff = _today(self.settings) - timedelta(days=retention_days) + warnings = 0 + deleted = 0 + unscheduled = 0 + old_workouts = generated_workouts_older_than( + client.get_workouts(limit=100), cutoff, self.settings.clone_prefix + ) + + for workout in old_workouts: + name = str(workout.get("workoutName") or "") + workout_id = workout.get("workoutId") or workout.get("id") + clone_date = generated_clone_date(name, self.settings.clone_prefix) + if clone_date is None or workout_id is None: + continue + try: + scheduled_ids = self._find_scheduled_ids(client, name, clone_date) + for scheduled_id in scheduled_ids: + client.unschedule_workout(scheduled_id) + unscheduled += 1 + client.delete_workout(str(workout_id)) + deleted += 1 + except Exception as exc: # noqa: BLE001 - cleanup should not block sync + warnings += 1 + self.repo.add_event( + run_id, + "warning", + "cleanup_old_clone_failed", + f"Failed to clean up old generated clone {name}: {exc}", + clone_date.isoformat(), + ) + + if deleted or unscheduled: + self.repo.add_event( + run_id, + "info", + "cleanup_old_clones", + ( + f"Cleaned up generated clones older than {retention_days} days: " + f"deleted {deleted}, unscheduled {unscheduled}" + ), + ) + return {"warnings": warnings} def _trace( self, @@ -324,7 +467,7 @@ class SyncService: return "skipped" if action == "replace_changed": return "replaced" - if action == "create": + if action in {"create", "recreate_missing"}: return "created" return "skipped" @@ -359,6 +502,10 @@ def _default_dates(days_ahead: int) -> list[date]: return [today + timedelta(days=offset) for offset in range(days_ahead + 1)] +def _today(settings: Settings) -> date: + return datetime.now(ZoneInfo(settings.timezone)).date() + + def _parse_time(value: str) -> time: hour, minute = value.split(":", 1) return time(int(hour), int(minute)) @@ -372,6 +519,17 @@ def _scheduled_id(value: Any) -> str | None: return None +def _workout_id(value: dict[str, Any] | None) -> str | None: + if not isinstance(value, dict): + return None + workout_id = value.get("workoutId") or value.get("id") + return None if workout_id is None else str(workout_id) + + +def _last_or_none(values: list[str]) -> str | None: + return values[-1] if values else None + + class _suppress_to_event: def __init__(self, repo: Repository) -> None: self.repo = repo diff --git a/src/garmin_coach_clone/workouts.py b/src/garmin_coach_clone/workouts.py index afa68c3..c80f797 100644 --- a/src/garmin_coach_clone/workouts.py +++ b/src/garmin_coach_clone/workouts.py @@ -155,14 +155,48 @@ def workout_source_hash(workout: dict[str, Any]) -> str: def existing_clone_names( workouts: list[dict[str, Any]], scheduled_date: date, prefix: str ) -> list[str]: - marker = f"{prefix} {scheduled_date.isoformat()}" return [ str(workout.get("workoutName")) + for workout in existing_clone_workouts(workouts, scheduled_date, prefix) + ] + + +def existing_clone_workouts( + workouts: list[dict[str, Any]], scheduled_date: date, prefix: str +) -> list[dict[str, Any]]: + marker = f"{prefix} {scheduled_date.isoformat()}" + return [ + workout for workout in workouts if str(workout.get("workoutName", "")).startswith(marker) ] +def generated_clone_date(name: str, prefix: str) -> date | None: + marker = f"{prefix} " + if not name.startswith(marker): + return None + raw = name[len(marker) : len(marker) + 10] + try: + parsed = date.fromisoformat(raw) + except ValueError: + return None + if len(name) > len(marker) + 10 and name[len(marker) + 10] != " ": + return None + return parsed + + +def generated_workouts_older_than( + workouts: list[dict[str, Any]], cutoff: date, prefix: str +) -> list[dict[str, Any]]: + old: list[dict[str, Any]] = [] + for workout in workouts: + clone_date = generated_clone_date(str(workout.get("workoutName", "")), prefix) + if clone_date is not None and clone_date < cutoff: + old.append(workout) + return old + + def generated_workouts(workouts: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]: return [ workout diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index cd07f43..2efecab 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import date, datetime +import garmin_coach_clone.sync_service as sync_service from garmin_coach_clone.config import load_settings from garmin_coach_clone.db import Database from garmin_coach_clone.repository import Repository, ScheduleConfig @@ -15,9 +16,12 @@ class FakeClient: self.scheduled: list[tuple[str, str]] = [] self.unscheduled: list[str] = [] self.deleted: list[str] = [] + self.workouts: list[dict] = [] + self.calendar_items: list[dict] = [] def get_workouts(self, limit: int = 100) -> list[dict]: - return [] + _ = limit + return self.workouts def upload_workout(self, payload: dict) -> dict: self.uploads.append(payload) @@ -33,6 +37,10 @@ class FakeClient: def delete_workout(self, workout_id: str) -> None: self.deleted.append(workout_id) + def get_scheduled_workouts(self, year: int, month: int) -> dict: + _ = year, month + return {"calendarItems": self.calendar_items} + class FakeGarmin: def __init__(self, source: dict | None) -> None: @@ -88,6 +96,7 @@ def test_sync_creates_missing_clone(tmp_path, monkeypatch) -> None: def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None: source = _source("Sprint") service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = "GCClone 2026-06-16 Sprint" repo.upsert_clone_mapping( { "scheduled_date": "2026-06-16", @@ -95,12 +104,20 @@ def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None: "source_hash": workout_source_hash(source), "source_name": "Sprint", "clone_workout_id": "w1", - "clone_workout_name": "GCClone 2026-06-16 Sprint", + "clone_workout_name": clone_name, "scheduled_workout_id": "s1", "status": "scheduled", "message": "current", } ) + garmin.client.workouts = [{"workoutId": "w1", "workoutName": clone_name}] + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s1", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + } + ] result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) @@ -108,6 +125,211 @@ def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None: assert garmin.client.uploads == [] +def test_skip_unchanged_removes_duplicate_scheduled_entries(tmp_path, monkeypatch) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = "GCClone 2026-06-16 Sprint" + repo.upsert_clone_mapping( + { + "scheduled_date": "2026-06-16", + "source_uuid": source["workoutUuid"], + "source_hash": workout_source_hash(source), + "source_name": "Sprint", + "clone_workout_id": "w1", + "clone_workout_name": clone_name, + "scheduled_workout_id": "s2", + "status": "scheduled", + "message": "current", + } + ) + garmin.client.workouts = [{"workoutId": "w1", "workoutName": clone_name}] + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s1", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + }, + { + "scheduledWorkoutId": "s2", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + }, + { + "scheduledWorkoutId": "s3", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + }, + ] + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["skipped"] == 1 + assert garmin.client.uploads == [] + assert garmin.client.unscheduled == ["s1", "s3"] + + +def test_current_mapping_recreates_when_garmin_clone_is_missing( + tmp_path, monkeypatch +) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + _store_current_mapping(repo, source, scheduled_id="s-old") + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["created"] == 1 + assert garmin.client.uploads + assert garmin.client.scheduled == [("w1", "2026-06-16")] + mapping = repo.get_clone_mapping("2026-06-16") + assert mapping is not None + assert mapping["clone_workout_id"] == "w1" + assert mapping["scheduled_workout_id"] == "s1" + assert repo.list_traces(limit=1)[0]["action"] == "recreate_missing" + + +def test_current_mapping_recreates_when_calendar_entry_is_missing( + tmp_path, monkeypatch +) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = _store_current_mapping(repo, source, scheduled_id="s-old") + garmin.client.workouts = [{"workoutId": "w-old", "workoutName": clone_name}] + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["created"] == 1 + assert garmin.client.uploads + assert garmin.client.scheduled == [("w1", "2026-06-16")] + assert garmin.client.unscheduled == [] + mapping = repo.get_clone_mapping("2026-06-16") + assert mapping is not None + assert mapping["clone_workout_id"] == "w1" + assert mapping["scheduled_workout_id"] == "s1" + + +def test_current_mapping_recreates_when_workout_template_is_missing( + tmp_path, monkeypatch +) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = _store_current_mapping(repo, source, scheduled_id="s-old") + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s-old", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + } + ] + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["created"] == 1 + assert garmin.client.unscheduled == ["s-old"] + assert garmin.client.scheduled == [("w1", "2026-06-16")] + mapping = repo.get_clone_mapping("2026-06-16") + assert mapping is not None + assert mapping["clone_workout_id"] == "w1" + assert mapping["scheduled_workout_id"] == "s1" + + +def test_sync_adopts_external_existing_clone_ids(tmp_path, monkeypatch) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = "GCClone 2026-06-16 Sprint" + garmin.client.workouts = [{"workoutId": "w-existing", "workoutName": clone_name}] + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s-existing", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + } + ] + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["warnings"] == 1 + assert garmin.client.uploads == [] + mapping = repo.get_clone_mapping("2026-06-16") + assert mapping is not None + assert mapping["clone_workout_id"] == "w-existing" + assert mapping["scheduled_workout_id"] == "s-existing" + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["skipped"] == 1 + assert garmin.client.uploads == [] + + +def test_replace_changed_unschedules_matching_calendar_entries( + tmp_path, monkeypatch +) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = "GCClone 2026-06-16 Sprint" + repo.upsert_clone_mapping( + { + "scheduled_date": "2026-06-16", + "source_uuid": source["workoutUuid"], + "source_hash": "old-hash", + "source_name": "Sprint", + "clone_workout_id": "w-old", + "clone_workout_name": clone_name, + "scheduled_workout_id": None, + "status": "scheduled", + "message": "old", + } + ) + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s-old-1", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + }, + { + "scheduledWorkoutId": "s-old-2", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": clone_name}, + }, + ] + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["replaced"] == 1 + assert garmin.client.unscheduled == ["s-old-1", "s-old-2"] + assert len(garmin.client.uploads) == 1 + + +def test_sync_deletes_generated_clones_older_than_retention(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(sync_service, "_today", lambda settings: date(2026, 6, 16)) + source = _source("Sprint") + service, _repo, garmin = _service(tmp_path, monkeypatch, source) + old_name = "GCClone 2026-06-10 Sprint" + recent_name = "GCClone 2026-06-11 Threshold" + garmin.client.workouts = [ + {"workoutId": "w-old", "workoutName": old_name}, + {"workoutId": "w-recent", "workoutName": recent_name}, + {"workoutId": "w-probe", "workoutName": "GCClone Probe Dummy 2026-06-01"}, + {"workoutId": "w-other", "workoutName": "Other 2026-06-01 Ride"}, + ] + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s-old", + "scheduledDate": "2026-06-10", + "workout": {"workoutName": old_name}, + }, + { + "scheduledWorkoutId": "s-recent", + "scheduledDate": "2026-06-11", + "workout": {"workoutName": recent_name}, + }, + ] + + service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert garmin.client.unscheduled == ["s-old"] + assert garmin.client.deleted == ["w-old"] + + def test_rest_day_with_existing_clone_warns_without_delete(tmp_path, monkeypatch) -> None: service, repo, garmin = _service(tmp_path, monkeypatch, None) repo.upsert_clone_mapping( @@ -145,3 +367,21 @@ def _source(name: str) -> dict: workout = build_dummy_cycling_workout(name) workout["workoutUuid"] = f"uuid-{name}" return workout + + +def _store_current_mapping(repo: Repository, source: dict, scheduled_id: str | None) -> str: + clone_name = "GCClone 2026-06-16 Sprint" + repo.upsert_clone_mapping( + { + "scheduled_date": "2026-06-16", + "source_uuid": source["workoutUuid"], + "source_hash": workout_source_hash(source), + "source_name": "Sprint", + "clone_workout_id": "w-old", + "clone_workout_name": clone_name, + "scheduled_workout_id": scheduled_id, + "status": "scheduled", + "message": "current", + } + ) + return clone_name diff --git a/tests/test_workouts.py b/tests/test_workouts.py index 2c7b7e8..6985461 100644 --- a/tests/test_workouts.py +++ b/tests/test_workouts.py @@ -12,7 +12,9 @@ from garmin_coach_clone.workouts import ( existing_clone_names, find_generated_calendar_entry, find_generated_workout, + generated_clone_date, generated_calendar_entries, + generated_workouts_older_than, generated_workouts, validate_workout_payload, ) @@ -68,6 +70,25 @@ def test_generated_workouts_filters_by_prefix() -> None: assert find_generated_workout(workouts, "3", "GCClone") is None +def test_generated_clone_date_requires_prefix_and_iso_date() -> None: + assert generated_clone_date("GCClone 2026-06-16 Ride", "GCClone") == date(2026, 6, 16) + assert generated_clone_date("GCClone Probe Dummy 2026-06-16", "GCClone") is None + assert generated_clone_date("Other 2026-06-16 Ride", "GCClone") is None + + +def test_generated_workouts_older_than_filters_by_clone_date() -> None: + workouts = [ + {"workoutId": 1, "workoutName": "GCClone 2026-06-10 Old"}, + {"workoutId": 2, "workoutName": "GCClone 2026-06-11 Cutoff"}, + {"workoutId": 3, "workoutName": "GCClone 2026-06-12 New"}, + {"workoutId": 4, "workoutName": "GCClone Probe Dummy 2026-06-01"}, + ] + + assert generated_workouts_older_than(workouts, date(2026, 6, 11), "GCClone") == [ + workouts[0] + ] + + def test_generated_calendar_entries_handle_nested_workout_names() -> None: generated_entry = { "scheduledWorkoutId": 99,