diff --git a/src/garmin_coach_clone/sync_service.py b/src/garmin_coach_clone/sync_service.py index 6792a3e..297a854 100644 --- a/src/garmin_coach_clone/sync_service.py +++ b/src/garmin_coach_clone/sync_service.py @@ -21,6 +21,7 @@ from .workouts import ( summarize_workout, validate_workout_payload, workout_source_hash, + workout_steps_equal, ) @@ -180,7 +181,11 @@ class SyncService: ): 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 + client, + client.get_workouts(limit=100), + mapping, + target_date, + payload, ) scheduled_ids = self._find_scheduled_ids(client, clone_name, target_date) if current_workout is not None and scheduled_ids: @@ -328,6 +333,9 @@ class SyncService: current_id = _workout_id(current_workout) if current_id is not None: updated["clone_workout_id"] = current_id + current_name = current_workout.get("workoutName") if current_workout else None + if current_name: + updated["clone_workout_name"] = str(current_name) if mapping.get("scheduled_workout_id") != keep: updated["scheduled_workout_id"] = keep if updated != mapping: @@ -339,18 +347,32 @@ class SyncService: return duplicates def _find_current_clone_workout( - self, workouts: list[dict[str, Any]], mapping: dict[str, Any], clone_name: str + self, + client: Any, + workouts: list[dict[str, Any]], + mapping: dict[str, Any], + target_date: date, + payload: dict[str, Any], ) -> 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 + for workout in existing_clone_workouts(workouts, target_date, self.settings.clone_prefix): + detail = self._workout_detail(client, workout) + if detail is not None and workout_steps_equal(detail, payload): + return detail return None + def _workout_detail(self, client: Any, workout: dict[str, Any]) -> dict[str, Any] | None: + workout_id = _workout_id(workout) + if workout_id is not None: + with suppress(Exception): + detail = client.get_workout_by_id(workout_id) + if isinstance(detail, dict): + return { + **detail, + "workoutId": detail.get("workoutId") or workout_id, + "workoutName": detail.get("workoutName") or workout.get("workoutName"), + } + return workout if isinstance(workout.get("workoutSegments"), list) else None + def _replace_existing( self, client: Any, mapping: dict[str, Any], target_date: date ) -> None: @@ -370,13 +392,19 @@ class SyncService: self, client: Any, clone_name: str, target_date: date ) -> list[str]: date_s = target_date.isoformat() + marker = f"{self.settings.clone_prefix} {date_s}" 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): 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: + name = calendar_entry_name(entry) + if ( + name is not None + and name.startswith(marker) + and calendar_entry_id(entry) is not None + ): ids.append(str(calendar_entry_id(entry))) return ids diff --git a/src/garmin_coach_clone/workouts.py b/src/garmin_coach_clone/workouts.py index c80f797..a140134 100644 --- a/src/garmin_coach_clone/workouts.py +++ b/src/garmin_coach_clone/workouts.py @@ -152,6 +152,10 @@ def workout_source_hash(workout: dict[str, Any]) -> str: return hashlib.sha256(encoded.encode("utf-8")).hexdigest() +def workout_steps_equal(left: dict[str, Any], right: dict[str, Any]) -> bool: + return _normalized_steps(left) == _normalized_steps(right) + + def existing_clone_names( workouts: list[dict[str, Any]], scheduled_date: date, prefix: str ) -> list[str]: @@ -242,7 +246,7 @@ def find_generated_calendar_entry( def calendar_entry_id(entry: dict[str, Any]) -> str | None: - for key in ("scheduledWorkoutId", "calendarItemId"): + for key in ("scheduledWorkoutId", "calendarItemId", "id"): value = entry.get(key) if value is not None: return str(value) @@ -377,6 +381,12 @@ def _strip_ids(value: Any) -> None: _strip_ids(item) +def _normalized_steps(workout: dict[str, Any]) -> Any: + value = deepcopy(workout.get("workoutSegments")) + _strip_ids(value) + return value + + def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]: seen: set[str | int] = set() deduped: list[dict[str, Any]] = [] diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index 2efecab..9b14e09 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -7,7 +7,11 @@ from garmin_coach_clone.config import load_settings from garmin_coach_clone.db import Database from garmin_coach_clone.repository import Repository, ScheduleConfig from garmin_coach_clone.sync_service import SyncService, should_run_now -from garmin_coach_clone.workouts import build_dummy_cycling_workout, workout_source_hash +from garmin_coach_clone.workouts import ( + build_dummy_cycling_workout, + clone_workout_payload, + workout_source_hash, +) class FakeClient: @@ -17,12 +21,21 @@ class FakeClient: self.unscheduled: list[str] = [] self.deleted: list[str] = [] self.workouts: list[dict] = [] + self.workout_details: dict[str, dict] = {} self.calendar_items: list[dict] = [] def get_workouts(self, limit: int = 100) -> list[dict]: _ = limit return self.workouts + def get_workout_by_id(self, workout_id: str) -> dict: + if str(workout_id) in self.workout_details: + return self.workout_details[str(workout_id)] + for workout in self.workouts: + if str(workout.get("workoutId") or workout.get("id")) == str(workout_id): + return workout + raise ValueError(f"unknown workout: {workout_id}") + def upload_workout(self, payload: dict) -> dict: self.uploads.append(payload) return {"workoutId": f"w{len(self.uploads)}"} @@ -110,7 +123,7 @@ def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None: "message": "current", } ) - garmin.client.workouts = [{"workoutId": "w1", "workoutName": clone_name}] + _install_garmin_clone(garmin, source, workout_id="w1", clone_name=clone_name) garmin.client.calendar_items = [ { "scheduledWorkoutId": "s1", @@ -125,6 +138,87 @@ def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None: assert garmin.client.uploads == [] +def test_sync_skips_unchanged_clone_with_calendar_id_key(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": "99", + "status": "scheduled", + "message": "current", + } + ) + _install_garmin_clone(garmin, source, workout_id="w1", clone_name=clone_name) + garmin.client.calendar_items = [ + { + "id": 99, + "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 == [] + + +def test_sync_matches_current_clone_by_date_name_marker_and_equal_steps( + tmp_path, monkeypatch +) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + mapped_name = _store_current_mapping(repo, source, scheduled_id="s1") + garmin_name = "GCClone 2026-06-16 Renamed" + _install_garmin_clone(garmin, source, workout_id="w-new", clone_name=garmin_name) + garmin.client.calendar_items = [ + { + "scheduledWorkoutId": "s1", + "scheduledDate": "2026-06-16", + "workout": {"workoutName": garmin_name}, + } + ] + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert mapped_name != garmin_name + assert result["skipped"] == 1 + assert garmin.client.uploads == [] + mapping = repo.get_clone_mapping("2026-06-16") + assert mapping is not None + assert mapping["clone_workout_id"] == "w-new" + assert mapping["clone_workout_name"] == garmin_name + + +def test_sync_recreates_date_name_match_with_different_steps(tmp_path, monkeypatch) -> None: + source = _source("Sprint") + changed_source = _source("Different") + changed_source["workoutSegments"][0]["workoutSteps"][0]["endConditionValue"] = 123 + service, repo, garmin = _service(tmp_path, monkeypatch, source) + clone_name = _store_current_mapping(repo, source, scheduled_id="s1") + _install_garmin_clone(garmin, changed_source, workout_id="w-old", clone_name=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) + + assert result["created"] == 1 + assert garmin.client.unscheduled == ["s1"] + assert garmin.client.scheduled == [("w1", "2026-06-16")] + + def test_skip_unchanged_removes_duplicate_scheduled_entries(tmp_path, monkeypatch) -> None: source = _source("Sprint") service, repo, garmin = _service(tmp_path, monkeypatch, source) @@ -142,7 +236,7 @@ def test_skip_unchanged_removes_duplicate_scheduled_entries(tmp_path, monkeypatc "message": "current", } ) - garmin.client.workouts = [{"workoutId": "w1", "workoutName": clone_name}] + _install_garmin_clone(garmin, source, workout_id="w1", clone_name=clone_name) garmin.client.calendar_items = [ { "scheduledWorkoutId": "s1", @@ -193,7 +287,7 @@ def test_current_mapping_recreates_when_calendar_entry_is_missing( 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}] + _install_garmin_clone(garmin, source, workout_id="w-old", clone_name=clone_name) result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) @@ -236,7 +330,7 @@ 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}] + _install_garmin_clone(garmin, source, workout_id="w-existing", clone_name=clone_name) garmin.client.calendar_items = [ { "scheduledWorkoutId": "s-existing", @@ -385,3 +479,19 @@ def _store_current_mapping(repo: Repository, source: dict, scheduled_id: str | N } ) return clone_name + + +def _install_garmin_clone( + garmin: FakeGarmin, + source: dict, + workout_id: str, + clone_name: str, +) -> None: + payload = clone_workout_payload(source, date(2026, 6, 16), "GCClone") + payload["workoutId"] = workout_id + payload["workoutName"] = clone_name + garmin.client.workouts = [ + *garmin.client.workouts, + {"workoutId": workout_id, "workoutName": clone_name}, + ] + garmin.client.workout_details[workout_id] = payload diff --git a/tests/test_workouts.py b/tests/test_workouts.py index 6985461..2794378 100644 --- a/tests/test_workouts.py +++ b/tests/test_workouts.py @@ -17,6 +17,7 @@ from garmin_coach_clone.workouts import ( generated_workouts_older_than, generated_workouts, validate_workout_payload, + workout_steps_equal, ) @@ -44,6 +45,21 @@ def test_clone_payload_strips_ids_and_sets_prefix() -> None: assert validate_workout_payload(cloned) == [] +def test_workout_steps_equal_ignores_ids_but_compares_steps() -> None: + left = build_dummy_cycling_workout("Left") + right = build_dummy_cycling_workout("Right") + left["workoutId"] = 1 + right["workoutId"] = 2 + left["workoutSegments"][0]["workoutSteps"][0]["stepId"] = 100 + right["workoutSegments"][0]["workoutSteps"][0]["stepId"] = 200 + + assert workout_steps_equal(left, right) + + right["workoutSegments"][0]["workoutSteps"][0]["endConditionValue"] = 123 + + assert not workout_steps_equal(left, right) + + def test_existing_clone_names_filters_by_prefix_and_date() -> None: names = existing_clone_names( [ @@ -116,6 +132,18 @@ def test_generated_calendar_entries_handle_nested_workout_names() -> None: assert calendar_entry_name(generated_entry) == "GCClone 2026-06-16 Ride" +def test_generated_calendar_entries_accept_id_as_calendar_entry_id() -> None: + generated_entry = { + "id": 99, + "scheduledDate": "2026-06-16", + "workout": {"workoutName": "GCClone 2026-06-16 Ride"}, + } + data = {"calendarItems": [generated_entry]} + + assert generated_calendar_entries(data, "GCClone") == [generated_entry] + assert calendar_entry_id(generated_entry) == "99" + + def test_validate_workout_payload_rejects_missing_segments() -> None: errors = validate_workout_payload( {