feat: stuff

This commit is contained in:
2026-06-16 19:23:32 +02:00
parent 9f67cc482c
commit d7ac74a5e1
5 changed files with 490 additions and 35 deletions

View File

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