feat: more stuff
This commit is contained in:
@ -21,6 +21,7 @@ from .workouts import (
|
|||||||
summarize_workout,
|
summarize_workout,
|
||||||
validate_workout_payload,
|
validate_workout_payload,
|
||||||
workout_source_hash,
|
workout_source_hash,
|
||||||
|
workout_steps_equal,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -180,7 +181,11 @@ class SyncService:
|
|||||||
):
|
):
|
||||||
clone_name = str(mapping.get("clone_workout_name") or payload["workoutName"])
|
clone_name = str(mapping.get("clone_workout_name") or payload["workoutName"])
|
||||||
current_workout = self._find_current_clone_workout(
|
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)
|
scheduled_ids = self._find_scheduled_ids(client, clone_name, target_date)
|
||||||
if current_workout is not None and scheduled_ids:
|
if current_workout is not None and scheduled_ids:
|
||||||
@ -328,6 +333,9 @@ class SyncService:
|
|||||||
current_id = _workout_id(current_workout)
|
current_id = _workout_id(current_workout)
|
||||||
if current_id is not None:
|
if current_id is not None:
|
||||||
updated["clone_workout_id"] = current_id
|
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:
|
if mapping.get("scheduled_workout_id") != keep:
|
||||||
updated["scheduled_workout_id"] = keep
|
updated["scheduled_workout_id"] = keep
|
||||||
if updated != mapping:
|
if updated != mapping:
|
||||||
@ -339,18 +347,32 @@ class SyncService:
|
|||||||
return duplicates
|
return duplicates
|
||||||
|
|
||||||
def _find_current_clone_workout(
|
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:
|
) -> dict[str, Any] | None:
|
||||||
mapped_id = mapping.get("clone_workout_id")
|
for workout in existing_clone_workouts(workouts, target_date, self.settings.clone_prefix):
|
||||||
for workout in workouts:
|
detail = self._workout_detail(client, workout)
|
||||||
workout_id = _workout_id(workout)
|
if detail is not None and workout_steps_equal(detail, payload):
|
||||||
workout_name = str(workout.get("workoutName") or "")
|
return detail
|
||||||
if mapped_id and workout_id == str(mapped_id):
|
|
||||||
return workout
|
|
||||||
if workout_name == clone_name:
|
|
||||||
return workout
|
|
||||||
return None
|
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(
|
def _replace_existing(
|
||||||
self, client: Any, mapping: dict[str, Any], target_date: date
|
self, client: Any, mapping: dict[str, Any], target_date: date
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -370,13 +392,19 @@ class SyncService:
|
|||||||
self, client: Any, clone_name: str, target_date: date
|
self, client: Any, clone_name: str, target_date: date
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
date_s = target_date.isoformat()
|
date_s = target_date.isoformat()
|
||||||
|
marker = f"{self.settings.clone_prefix} {date_s}"
|
||||||
calendar = client.get_scheduled_workouts(target_date.year, target_date.month)
|
calendar = client.get_scheduled_workouts(target_date.year, target_date.month)
|
||||||
ids: list[str] = []
|
ids: list[str] = []
|
||||||
for entry in generated_calendar_entries(calendar, self.settings.clone_prefix):
|
for entry in generated_calendar_entries(calendar, self.settings.clone_prefix):
|
||||||
entry_date = calendar_entry_date(entry)
|
entry_date = calendar_entry_date(entry)
|
||||||
if entry_date is not None and entry_date != date_s:
|
if entry_date is not None and entry_date != date_s:
|
||||||
continue
|
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)))
|
ids.append(str(calendar_entry_id(entry)))
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|||||||
@ -152,6 +152,10 @@ def workout_source_hash(workout: dict[str, Any]) -> str:
|
|||||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
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(
|
def existing_clone_names(
|
||||||
workouts: list[dict[str, Any]], scheduled_date: date, prefix: str
|
workouts: list[dict[str, Any]], scheduled_date: date, prefix: str
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
@ -242,7 +246,7 @@ def find_generated_calendar_entry(
|
|||||||
|
|
||||||
|
|
||||||
def calendar_entry_id(entry: dict[str, Any]) -> str | None:
|
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)
|
value = entry.get(key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
return str(value)
|
return str(value)
|
||||||
@ -377,6 +381,12 @@ def _strip_ids(value: Any) -> None:
|
|||||||
_strip_ids(item)
|
_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]]:
|
def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
seen: set[str | int] = set()
|
seen: set[str | int] = set()
|
||||||
deduped: list[dict[str, Any]] = []
|
deduped: list[dict[str, Any]] = []
|
||||||
|
|||||||
@ -7,7 +7,11 @@ from garmin_coach_clone.config import load_settings
|
|||||||
from garmin_coach_clone.db import Database
|
from garmin_coach_clone.db import Database
|
||||||
from garmin_coach_clone.repository import Repository, ScheduleConfig
|
from garmin_coach_clone.repository import Repository, ScheduleConfig
|
||||||
from garmin_coach_clone.sync_service import SyncService, should_run_now
|
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:
|
class FakeClient:
|
||||||
@ -17,12 +21,21 @@ class FakeClient:
|
|||||||
self.unscheduled: list[str] = []
|
self.unscheduled: list[str] = []
|
||||||
self.deleted: list[str] = []
|
self.deleted: list[str] = []
|
||||||
self.workouts: list[dict] = []
|
self.workouts: list[dict] = []
|
||||||
|
self.workout_details: dict[str, dict] = {}
|
||||||
self.calendar_items: list[dict] = []
|
self.calendar_items: list[dict] = []
|
||||||
|
|
||||||
def get_workouts(self, limit: int = 100) -> list[dict]:
|
def get_workouts(self, limit: int = 100) -> list[dict]:
|
||||||
_ = limit
|
_ = limit
|
||||||
return self.workouts
|
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:
|
def upload_workout(self, payload: dict) -> dict:
|
||||||
self.uploads.append(payload)
|
self.uploads.append(payload)
|
||||||
return {"workoutId": f"w{len(self.uploads)}"}
|
return {"workoutId": f"w{len(self.uploads)}"}
|
||||||
@ -110,7 +123,7 @@ def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None:
|
|||||||
"message": "current",
|
"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 = [
|
garmin.client.calendar_items = [
|
||||||
{
|
{
|
||||||
"scheduledWorkoutId": "s1",
|
"scheduledWorkoutId": "s1",
|
||||||
@ -125,6 +138,87 @@ def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None:
|
|||||||
assert garmin.client.uploads == []
|
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:
|
def test_skip_unchanged_removes_duplicate_scheduled_entries(tmp_path, monkeypatch) -> None:
|
||||||
source = _source("Sprint")
|
source = _source("Sprint")
|
||||||
service, repo, garmin = _service(tmp_path, monkeypatch, source)
|
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",
|
"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 = [
|
garmin.client.calendar_items = [
|
||||||
{
|
{
|
||||||
"scheduledWorkoutId": "s1",
|
"scheduledWorkoutId": "s1",
|
||||||
@ -193,7 +287,7 @@ def test_current_mapping_recreates_when_calendar_entry_is_missing(
|
|||||||
source = _source("Sprint")
|
source = _source("Sprint")
|
||||||
service, repo, garmin = _service(tmp_path, monkeypatch, source)
|
service, repo, garmin = _service(tmp_path, monkeypatch, source)
|
||||||
clone_name = _store_current_mapping(repo, source, scheduled_id="s-old")
|
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)
|
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")
|
source = _source("Sprint")
|
||||||
service, repo, garmin = _service(tmp_path, monkeypatch, source)
|
service, repo, garmin = _service(tmp_path, monkeypatch, source)
|
||||||
clone_name = "GCClone 2026-06-16 Sprint"
|
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 = [
|
garmin.client.calendar_items = [
|
||||||
{
|
{
|
||||||
"scheduledWorkoutId": "s-existing",
|
"scheduledWorkoutId": "s-existing",
|
||||||
@ -385,3 +479,19 @@ def _store_current_mapping(repo: Repository, source: dict, scheduled_id: str | N
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return clone_name
|
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
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from garmin_coach_clone.workouts import (
|
|||||||
generated_workouts_older_than,
|
generated_workouts_older_than,
|
||||||
generated_workouts,
|
generated_workouts,
|
||||||
validate_workout_payload,
|
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) == []
|
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:
|
def test_existing_clone_names_filters_by_prefix_and_date() -> None:
|
||||||
names = existing_clone_names(
|
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"
|
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:
|
def test_validate_workout_payload_rejects_missing_segments() -> None:
|
||||||
errors = validate_workout_payload(
|
errors = validate_workout_payload(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user