234 lines
7.9 KiB
Python
234 lines
7.9 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
from garmin_coach_clone.workouts import (
|
||
build_dummy_cycling_workout,
|
||
calendar_entry_date,
|
||
calendar_entry_id,
|
||
calendar_entry_name,
|
||
clone_workout_description,
|
||
clone_workout_name,
|
||
clone_workout_payload,
|
||
estimate_duration,
|
||
existing_clone_names,
|
||
find_generated_calendar_entry,
|
||
find_generated_workout,
|
||
generated_calendar_entries,
|
||
generated_clone_date,
|
||
generated_workouts,
|
||
generated_workouts_older_than,
|
||
validate_workout_payload,
|
||
workout_steps_equal,
|
||
)
|
||
|
||
|
||
def test_dummy_workout_has_expected_duration_and_steps() -> None:
|
||
workout = build_dummy_cycling_workout("Dummy")
|
||
|
||
assert workout["workoutName"] == "Dummy"
|
||
assert workout["sportType"]["sportTypeKey"] == "cycling"
|
||
assert estimate_duration(workout) == 14 * 60
|
||
assert validate_workout_payload(workout) == []
|
||
|
||
|
||
def test_clone_payload_strips_ids_and_sets_prefix() -> None:
|
||
source = build_dummy_cycling_workout("Coach Original")
|
||
source["workoutId"] = 123
|
||
source["ownerId"] = 456
|
||
source["workoutSegments"][0]["workoutSteps"][0]["stepId"] = 789
|
||
|
||
cloned = clone_workout_payload(source, date(2026, 6, 16), "GCClone")
|
||
|
||
assert cloned["workoutName"] == "Coach Original June 16, 2026 (gcclone)"
|
||
assert "workoutId" not in cloned
|
||
assert "ownerId" not in cloned
|
||
assert "stepId" not in cloned["workoutSegments"][0]["workoutSteps"][0]
|
||
assert validate_workout_payload(cloned) == []
|
||
|
||
|
||
def test_clone_title_and_description_use_source_details() -> None:
|
||
source = build_dummy_cycling_workout("Tempo")
|
||
source["description"] = "3x10:00@180W"
|
||
steps = source["workoutSegments"][0]["workoutSteps"]
|
||
steps[0]["targetType"] = {"workoutTargetTypeKey": "power.zone"}
|
||
steps[0]["targetValueOne"] = 98
|
||
steps[0]["targetValueTwo"] = 142
|
||
|
||
assert clone_workout_name(
|
||
"Tempo", date(2026, 7, 15), "GCClone", "{type} {date} ({clone})"
|
||
) == "Tempo July 15, 2026 (gcclone)"
|
||
assert clone_workout_description(source) == (
|
||
"3x10:00@180W\n"
|
||
"Intervals: Warm up 5:00 @ 98–142 W | 1× (Interval 2:00 | Recover 2:00) | "
|
||
"Cool down 5:00\n"
|
||
"GCClone — cloned from Garmin Coach; completing it may not update the Coach plan."
|
||
)
|
||
|
||
|
||
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_workout_steps_equal_ignores_garmin_upload_defaults() -> None:
|
||
left = build_dummy_cycling_workout("Left")
|
||
right = build_dummy_cycling_workout("Right")
|
||
repeat = right["workoutSegments"][0]["workoutSteps"][1]
|
||
repeat["stepOrder"] = 2
|
||
repeat["childStepId"] = 1
|
||
repeat["smartRepeat"] = False
|
||
for idx, step in enumerate(right["workoutSegments"][0]["workoutSteps"], start=1):
|
||
step["stepId"] = 1000 + idx
|
||
step["stepOrder"] = idx
|
||
step["equipmentType"] = {
|
||
"displayOrder": 0,
|
||
"equipmentTypeId": 0,
|
||
"equipmentTypeKey": None,
|
||
}
|
||
step["strokeType"] = {
|
||
"displayOrder": 0,
|
||
"strokeTypeId": 0,
|
||
"strokeTypeKey": None,
|
||
}
|
||
for idx, step in enumerate(repeat["workoutSteps"], start=3):
|
||
step["childStepId"] = 1
|
||
step["stepId"] = 1000 + idx
|
||
step["stepOrder"] = idx
|
||
step["equipmentType"] = {
|
||
"displayOrder": 0,
|
||
"equipmentTypeId": 0,
|
||
"equipmentTypeKey": None,
|
||
}
|
||
step["strokeType"] = {
|
||
"displayOrder": 0,
|
||
"strokeTypeId": 0,
|
||
"strokeTypeKey": None,
|
||
}
|
||
|
||
assert workout_steps_equal(left, right)
|
||
|
||
|
||
def test_existing_clone_names_filters_by_prefix_and_date() -> None:
|
||
names = existing_clone_names(
|
||
[
|
||
{"workoutName": "GCClone 2026-06-16 Ride"},
|
||
{"workoutName": "GCClone 2026-06-17 Ride"},
|
||
{"workoutName": "Other 2026-06-16 Ride"},
|
||
],
|
||
date(2026, 6, 16),
|
||
"GCClone",
|
||
)
|
||
|
||
assert names == ["GCClone 2026-06-16 Ride"]
|
||
|
||
|
||
def test_generated_workouts_filters_by_prefix() -> None:
|
||
workouts = [
|
||
{"workoutId": 1, "workoutName": "GCClone 2026-06-16 Ride"},
|
||
{"workoutId": 2, "workoutName": "GCClone Probe Dummy 2026-06-16"},
|
||
{"workoutId": 3, "workoutName": "Real Workout"},
|
||
]
|
||
|
||
assert generated_workouts(workouts, "GCClone") == workouts[:2]
|
||
assert find_generated_workout(workouts, "2", "GCClone") == workouts[1]
|
||
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:
|
||
generated_entry = {
|
||
"scheduledWorkoutId": 99,
|
||
"scheduledDate": "2026-06-16",
|
||
"workout": {"workoutName": "GCClone 2026-06-16 Ride"},
|
||
}
|
||
data = {
|
||
"calendarItems": [
|
||
generated_entry,
|
||
{
|
||
"scheduledWorkoutId": 100,
|
||
"scheduledDate": "2026-06-16",
|
||
"workout": {"workoutName": "Real Workout"},
|
||
},
|
||
]
|
||
}
|
||
|
||
entries = generated_calendar_entries(data, "GCClone")
|
||
|
||
assert entries == [generated_entry]
|
||
assert find_generated_calendar_entry(data, "99", "GCClone") == generated_entry
|
||
assert find_generated_calendar_entry(data, "100", "GCClone") is None
|
||
assert calendar_entry_id(generated_entry) == "99"
|
||
assert calendar_entry_date(generated_entry) == "2026-06-16"
|
||
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(
|
||
{
|
||
"workoutName": "Broken",
|
||
"sportType": {"sportTypeKey": "cycling"},
|
||
"workoutSegments": [],
|
||
}
|
||
)
|
||
|
||
assert "workoutSegments must be a non-empty list" in errors
|
||
|
||
|
||
def test_validate_workout_payload_rejects_non_cycling() -> None:
|
||
workout = build_dummy_cycling_workout("Broken")
|
||
workout["sportType"] = {"sportTypeKey": "running"}
|
||
|
||
errors = validate_workout_payload(workout)
|
||
|
||
assert "sportType must be cycling, got running" in errors
|
||
|
||
|
||
def test_validate_workout_payload_rejects_missing_step_target() -> None:
|
||
workout = build_dummy_cycling_workout("Broken")
|
||
workout["workoutSegments"][0]["workoutSteps"][0].pop("targetType")
|
||
|
||
errors = validate_workout_payload(workout)
|
||
|
||
assert "workoutSegments[1].workoutSteps[1].targetType is missing" in errors
|