feat: stuff
This commit is contained in:
@ -41,6 +41,7 @@ class Settings:
|
|||||||
sync_days_ahead: int
|
sync_days_ahead: int
|
||||||
overwrite_existing: bool
|
overwrite_existing: bool
|
||||||
delete_old_clones: bool
|
delete_old_clones: bool
|
||||||
|
clone_retention_days: int
|
||||||
change_interval_minutes: int
|
change_interval_minutes: int
|
||||||
change_active_window: str
|
change_active_window: str
|
||||||
change_fixed_times: list[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"))),
|
sync_days_ahead=max(0, int(os.getenv("SYNC_DAYS_AHEAD", "1"))),
|
||||||
overwrite_existing=_bool_env("OVERWRITE_EXISTING", True),
|
overwrite_existing=_bool_env("OVERWRITE_EXISTING", True),
|
||||||
delete_old_clones=_bool_env("DELETE_OLD_CLONES", False),
|
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(
|
change_interval_minutes=max(
|
||||||
5, int(os.getenv("CHANGE_DETECTION_INTERVAL_MINUTES", str(DEFAULT_INTERVAL_MINUTES)))
|
5, int(os.getenv("CHANGE_DETECTION_INTERVAL_MINUTES", str(DEFAULT_INTERVAL_MINUTES)))
|
||||||
),
|
),
|
||||||
|
|||||||
@ -10,10 +10,14 @@ from .config import Settings
|
|||||||
from .io import redact
|
from .io import redact
|
||||||
from .repository import Repository, ScheduleConfig
|
from .repository import Repository, ScheduleConfig
|
||||||
from .workouts import (
|
from .workouts import (
|
||||||
|
calendar_entry_date,
|
||||||
calendar_entry_id,
|
calendar_entry_id,
|
||||||
|
calendar_entry_name,
|
||||||
clone_workout_payload,
|
clone_workout_payload,
|
||||||
existing_clone_names,
|
existing_clone_workouts,
|
||||||
generated_calendar_entries,
|
generated_calendar_entries,
|
||||||
|
generated_clone_date,
|
||||||
|
generated_workouts_older_than,
|
||||||
summarize_workout,
|
summarize_workout,
|
||||||
validate_workout_payload,
|
validate_workout_payload,
|
||||||
workout_source_hash,
|
workout_source_hash,
|
||||||
@ -108,6 +112,9 @@ class SyncService:
|
|||||||
for target_date in dates:
|
for target_date in dates:
|
||||||
decision = self._sync_date(run_id, client, detail, target_date, dry_run)
|
decision = self._sync_date(run_id, client, detail, target_date, dry_run)
|
||||||
counts[decision] = counts.get(decision, 0) + 1
|
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)
|
self.repo.finish_sync_run(run_id, status, counts)
|
||||||
return {"run_id": run_id, "status": status, **counts}
|
return {"run_id": run_id, "status": status, **counts}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -165,35 +172,69 @@ class SyncService:
|
|||||||
source_hash=source_hash,
|
source_hash=source_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stale_scheduled_ids: list[str] = []
|
||||||
if (
|
if (
|
||||||
mapping
|
mapping
|
||||||
and mapping.get("source_hash") == source_hash
|
and mapping.get("source_hash") == source_hash
|
||||||
and mapping.get("clone_workout_id")
|
and mapping.get("clone_workout_id")
|
||||||
):
|
):
|
||||||
return self._trace(
|
clone_name = str(mapping.get("clone_workout_name") or payload["workoutName"])
|
||||||
run_id,
|
current_workout = self._find_current_clone_workout(
|
||||||
date_s,
|
client.get_workouts(limit=100), mapping, clone_name
|
||||||
source,
|
|
||||||
payload,
|
|
||||||
"skip_unchanged",
|
|
||||||
"skipped",
|
|
||||||
"Generated clone is current",
|
|
||||||
source_hash=source_hash,
|
|
||||||
)
|
)
|
||||||
|
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
|
client.get_workouts(limit=100), target_date, self.settings.clone_prefix
|
||||||
)
|
)
|
||||||
if not mapping and existing:
|
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(
|
self.repo.upsert_clone_mapping(
|
||||||
{
|
{
|
||||||
"scheduled_date": date_s,
|
"scheduled_date": date_s,
|
||||||
"source_uuid": source_uuid,
|
"source_uuid": source_uuid,
|
||||||
"source_hash": source_hash,
|
"source_hash": source_hash,
|
||||||
"source_name": source_name,
|
"source_name": source_name,
|
||||||
"clone_workout_id": None,
|
"clone_workout_id": None if existing_id is None else str(existing_id),
|
||||||
"clone_workout_name": existing[0],
|
"clone_workout_name": existing_name,
|
||||||
"scheduled_workout_id": None,
|
"scheduled_workout_id": _last_or_none(existing_scheduled_ids),
|
||||||
"status": "external_existing",
|
"status": "external_existing",
|
||||||
"message": "Generated clone exists in Garmin but is not mapped locally",
|
"message": "Generated clone exists in Garmin but is not mapped locally",
|
||||||
}
|
}
|
||||||
@ -209,7 +250,6 @@ class SyncService:
|
|||||||
source_hash=source_hash,
|
source_hash=source_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
action = "create" if mapping is None else "replace_changed"
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return self._trace(
|
return self._trace(
|
||||||
run_id,
|
run_id,
|
||||||
@ -222,16 +262,19 @@ class SyncService:
|
|||||||
source_hash=source_hash,
|
source_hash=source_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
if mapping and mapping.get("clone_workout_id"):
|
if mapping and action != "recreate_missing":
|
||||||
self._replace_existing(client, mapping)
|
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)
|
upload_result = client.upload_workout(payload)
|
||||||
workout_id = upload_result.get("workoutId") or upload_result.get("id")
|
workout_id = upload_result.get("workoutId") or upload_result.get("id")
|
||||||
if workout_id is None:
|
if workout_id is None:
|
||||||
raise ValueError(f"Upload returned no workoutId: {upload_result}")
|
raise ValueError(f"Upload returned no workoutId: {upload_result}")
|
||||||
schedule_result = client.schedule_workout(workout_id, date_s)
|
schedule_result = client.schedule_workout(workout_id, date_s)
|
||||||
scheduled_id = _scheduled_id(schedule_result) or self._find_scheduled_id(
|
scheduled_id = _scheduled_id(schedule_result) or _last_or_none(
|
||||||
client, payload, target_date
|
self._find_scheduled_ids(client, str(payload["workoutName"]), target_date)
|
||||||
)
|
)
|
||||||
self.repo.upsert_clone_mapping(
|
self.repo.upsert_clone_mapping(
|
||||||
{
|
{
|
||||||
@ -263,24 +306,124 @@ class SyncService:
|
|||||||
return "created"
|
return "created"
|
||||||
return status_key
|
return status_key
|
||||||
|
|
||||||
def _replace_existing(self, client: Any, mapping: dict[str, Any]) -> None:
|
def _repair_current_schedule(
|
||||||
scheduled_id = mapping.get("scheduled_workout_id")
|
self,
|
||||||
if scheduled_id:
|
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))
|
client.unschedule_workout(str(scheduled_id))
|
||||||
if self.settings.delete_old_clones and mapping.get("clone_workout_id"):
|
if self.settings.delete_old_clones and mapping.get("clone_workout_id"):
|
||||||
client.delete_workout(str(mapping["clone_workout_id"]))
|
client.delete_workout(str(mapping["clone_workout_id"]))
|
||||||
|
|
||||||
def _find_scheduled_id(
|
def _find_scheduled_ids(
|
||||||
self, client: Any, payload: dict[str, Any], target_date: date
|
self, client: Any, clone_name: str, target_date: date
|
||||||
) -> str | None:
|
) -> list[str]:
|
||||||
|
date_s = target_date.isoformat()
|
||||||
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] = []
|
||||||
for entry in generated_calendar_entries(calendar, self.settings.clone_prefix):
|
for entry in generated_calendar_entries(calendar, self.settings.clone_prefix):
|
||||||
if (
|
entry_date = calendar_entry_date(entry)
|
||||||
entry.get("workoutName") == payload["workoutName"]
|
if entry_date is not None and entry_date != date_s:
|
||||||
or entry.get("title") == payload["workoutName"]
|
continue
|
||||||
):
|
if calendar_entry_name(entry) == clone_name and calendar_entry_id(entry) is not None:
|
||||||
return calendar_entry_id(entry)
|
ids.append(str(calendar_entry_id(entry)))
|
||||||
return None
|
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(
|
def _trace(
|
||||||
self,
|
self,
|
||||||
@ -324,7 +467,7 @@ class SyncService:
|
|||||||
return "skipped"
|
return "skipped"
|
||||||
if action == "replace_changed":
|
if action == "replace_changed":
|
||||||
return "replaced"
|
return "replaced"
|
||||||
if action == "create":
|
if action in {"create", "recreate_missing"}:
|
||||||
return "created"
|
return "created"
|
||||||
return "skipped"
|
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)]
|
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:
|
def _parse_time(value: str) -> time:
|
||||||
hour, minute = value.split(":", 1)
|
hour, minute = value.split(":", 1)
|
||||||
return time(int(hour), int(minute))
|
return time(int(hour), int(minute))
|
||||||
@ -372,6 +519,17 @@ def _scheduled_id(value: Any) -> str | None:
|
|||||||
return 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:
|
class _suppress_to_event:
|
||||||
def __init__(self, repo: Repository) -> None:
|
def __init__(self, repo: Repository) -> None:
|
||||||
self.repo = repo
|
self.repo = repo
|
||||||
|
|||||||
@ -155,14 +155,48 @@ def workout_source_hash(workout: dict[str, Any]) -> str:
|
|||||||
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]:
|
||||||
marker = f"{prefix} {scheduled_date.isoformat()}"
|
|
||||||
return [
|
return [
|
||||||
str(workout.get("workoutName"))
|
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
|
for workout in workouts
|
||||||
if str(workout.get("workoutName", "")).startswith(marker)
|
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]]:
|
def generated_workouts(workouts: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
workout
|
workout
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime
|
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.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
|
||||||
@ -15,9 +16,12 @@ class FakeClient:
|
|||||||
self.scheduled: list[tuple[str, str]] = []
|
self.scheduled: list[tuple[str, str]] = []
|
||||||
self.unscheduled: list[str] = []
|
self.unscheduled: list[str] = []
|
||||||
self.deleted: list[str] = []
|
self.deleted: list[str] = []
|
||||||
|
self.workouts: 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]:
|
||||||
return []
|
_ = limit
|
||||||
|
return self.workouts
|
||||||
|
|
||||||
def upload_workout(self, payload: dict) -> dict:
|
def upload_workout(self, payload: dict) -> dict:
|
||||||
self.uploads.append(payload)
|
self.uploads.append(payload)
|
||||||
@ -33,6 +37,10 @@ class FakeClient:
|
|||||||
def delete_workout(self, workout_id: str) -> None:
|
def delete_workout(self, workout_id: str) -> None:
|
||||||
self.deleted.append(workout_id)
|
self.deleted.append(workout_id)
|
||||||
|
|
||||||
|
def get_scheduled_workouts(self, year: int, month: int) -> dict:
|
||||||
|
_ = year, month
|
||||||
|
return {"calendarItems": self.calendar_items}
|
||||||
|
|
||||||
|
|
||||||
class FakeGarmin:
|
class FakeGarmin:
|
||||||
def __init__(self, source: dict | None) -> None:
|
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:
|
def test_sync_skips_unchanged_clone(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"
|
||||||
repo.upsert_clone_mapping(
|
repo.upsert_clone_mapping(
|
||||||
{
|
{
|
||||||
"scheduled_date": "2026-06-16",
|
"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_hash": workout_source_hash(source),
|
||||||
"source_name": "Sprint",
|
"source_name": "Sprint",
|
||||||
"clone_workout_id": "w1",
|
"clone_workout_id": "w1",
|
||||||
"clone_workout_name": "GCClone 2026-06-16 Sprint",
|
"clone_workout_name": clone_name,
|
||||||
"scheduled_workout_id": "s1",
|
"scheduled_workout_id": "s1",
|
||||||
"status": "scheduled",
|
"status": "scheduled",
|
||||||
"message": "current",
|
"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)
|
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 == []
|
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:
|
def test_rest_day_with_existing_clone_warns_without_delete(tmp_path, monkeypatch) -> None:
|
||||||
service, repo, garmin = _service(tmp_path, monkeypatch, None)
|
service, repo, garmin = _service(tmp_path, monkeypatch, None)
|
||||||
repo.upsert_clone_mapping(
|
repo.upsert_clone_mapping(
|
||||||
@ -145,3 +367,21 @@ def _source(name: str) -> dict:
|
|||||||
workout = build_dummy_cycling_workout(name)
|
workout = build_dummy_cycling_workout(name)
|
||||||
workout["workoutUuid"] = f"uuid-{name}"
|
workout["workoutUuid"] = f"uuid-{name}"
|
||||||
return workout
|
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
|
||||||
|
|||||||
@ -12,7 +12,9 @@ from garmin_coach_clone.workouts import (
|
|||||||
existing_clone_names,
|
existing_clone_names,
|
||||||
find_generated_calendar_entry,
|
find_generated_calendar_entry,
|
||||||
find_generated_workout,
|
find_generated_workout,
|
||||||
|
generated_clone_date,
|
||||||
generated_calendar_entries,
|
generated_calendar_entries,
|
||||||
|
generated_workouts_older_than,
|
||||||
generated_workouts,
|
generated_workouts,
|
||||||
validate_workout_payload,
|
validate_workout_payload,
|
||||||
)
|
)
|
||||||
@ -68,6 +70,25 @@ def test_generated_workouts_filters_by_prefix() -> None:
|
|||||||
assert find_generated_workout(workouts, "3", "GCClone") is 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:
|
def test_generated_calendar_entries_handle_nested_workout_names() -> None:
|
||||||
generated_entry = {
|
generated_entry = {
|
||||||
"scheduledWorkoutId": 99,
|
"scheduledWorkoutId": 99,
|
||||||
|
|||||||
Reference in New Issue
Block a user