feat: more stuff

This commit is contained in:
2026-06-16 19:32:37 +02:00
parent d7ac74a5e1
commit 2e25e7eb34
4 changed files with 193 additions and 17 deletions

View File

@ -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

View File

@ -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(
{