43bbbb6bbb
New suites assert real behavior, not just that code runs: - test_types: workout serialization round-trips (recursive steps, camelCase keys, enum conversion) + __str__ formatting. - test_api_client: request construction (URL/method/auth/body) and the full HTTP status-code -> message mapping. - test_auth: RS256 JWT verification — valid -> AccessToken; expired/wrong-aud/ wrong-issuer/wrong-key/missing-claim -> None; audience slash variants. - test_server_setup: transport selection + start_server dispatch. - test_events / test_activities / test_custom_items: request payloads (create vs update, POST/PUT/DELETE), delete accounting, JSON-content parsing, and error/empty branches. Enforce >=90 via pytest --cov-fail-under=90; CI test job now gates the build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""
|
|
Tests for the workout data model in intervals_mcp_server.utils.types.
|
|
|
|
These exercise the serialization logic that builds ``add_or_update_event`` payloads:
|
|
round-trips (to_dict/from_dict/to_json/from_json), enum conversion, the camelCase
|
|
API key mapping, recursive nested steps, and the human-readable ``__str__`` output.
|
|
A bug in any branch (wrong key, missed enum conversion, broken recursion) fails here.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from intervals_mcp_server.utils.types import (
|
|
HrTarget,
|
|
Intensity,
|
|
PaceUnits,
|
|
SportSettings,
|
|
Step,
|
|
Value,
|
|
ValueUnits,
|
|
WorkoutDoc,
|
|
WorkoutTarget,
|
|
float_to_str,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# float_to_str
|
|
# --------------------------------------------------------------------------- #
|
|
def test_float_to_str_drops_trailing_zero():
|
|
assert float_to_str(95.0) == "95"
|
|
assert float_to_str(1.5) == "1.5"
|
|
assert float_to_str(0.0) == "0"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Value
|
|
# --------------------------------------------------------------------------- #
|
|
def test_value_to_dict_only_includes_set_fields():
|
|
assert Value(value=200, units=ValueUnits.WATTS).to_dict() == {"value": 200, "units": "w"}
|
|
# unset fields must be omitted, not serialized as null
|
|
assert "start" not in Value(value=1).to_dict()
|
|
|
|
|
|
def test_value_roundtrip_dict_and_json():
|
|
original = Value(start=65, end=85, units=ValueUnits.PERCENT_FTP, target=HrTarget.THREE_SECOND)
|
|
assert Value.from_dict(original.to_dict()) == original
|
|
assert Value.from_json(original.to_json()) == original
|
|
|
|
|
|
def test_value_from_dict_converts_enums():
|
|
val = Value.from_dict({"value": 3, "units": "power_zone", "target": "lap"})
|
|
assert val.units is ValueUnits.POWER_ZONE
|
|
assert val.target is HrTarget.LAP
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,units,expected",
|
|
[
|
|
(95.0, ValueUnits.PERCENT_FTP, "95% ftp"),
|
|
(200.0, ValueUnits.WATTS, "200W"),
|
|
(3.0, ValueUnits.POWER_ZONE, "Z3 W"),
|
|
(90.0, ValueUnits.CADENCE, "90rpm Cadence"),
|
|
(150.0, ValueUnits.PERCENT_HR, "150% HR"),
|
|
],
|
|
)
|
|
def test_value_str_formats_by_unit(value, units, expected):
|
|
assert str(Value(value=value, units=units)) == expected
|
|
|
|
|
|
def test_value_str_ramp_and_target():
|
|
assert str(Value(start=65, end=85, units=ValueUnits.PERCENT_FTP)) == "65%-85% ftp"
|
|
assert str(Value(value=150, target=HrTarget.LAP)) == "150 hr=lap"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Step
|
|
# --------------------------------------------------------------------------- #
|
|
def test_step_roundtrip_with_nested_steps_and_values():
|
|
"""Recursive round-trip: a repeat block containing a step with power/hr targets."""
|
|
original = Step(
|
|
reps=3,
|
|
intensity=Intensity.INTERVAL,
|
|
steps=[
|
|
Step(
|
|
duration=300,
|
|
power=Value(value=250, units=ValueUnits.WATTS),
|
|
hr=Value(start=150, end=165, units=ValueUnits.PERCENT_LTHR),
|
|
cadence=Value(value=90, units=ValueUnits.CADENCE),
|
|
),
|
|
Step(duration=60, freeride=True),
|
|
],
|
|
)
|
|
as_dict = original.to_dict()
|
|
# nested steps must serialize recursively, and enums become their .value
|
|
assert as_dict["intensity"] == "interval"
|
|
assert as_dict["steps"][0]["power"] == {"value": 250, "units": "w"}
|
|
assert isinstance(as_dict["steps"], list) and len(as_dict["steps"]) == 2
|
|
# full round-trip preserves the structure
|
|
assert Step.from_dict(as_dict) == original
|
|
assert Step.from_json(original.to_json()) == original
|
|
|
|
|
|
def test_step_to_dict_omits_unset_and_serializes_resolved_fields():
|
|
step = Step(duration=120, _power=Value(value=248, units=ValueUnits.WATTS), _distance=1000.0)
|
|
data = step.to_dict()
|
|
assert data == {"duration": 120, "_power": {"value": 248, "units": "w"}, "_distance": 1000.0}
|
|
assert "hr" not in data
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"duration,expected",
|
|
[
|
|
(45, "45s"),
|
|
(120, "2m"),
|
|
(125, "2m5s"),
|
|
(3720, "1h2m"),
|
|
],
|
|
)
|
|
def test_step_format_duration(duration, expected):
|
|
assert Step(duration=duration)._format_duration() == expected # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.parametrize("distance,expected", [(500, "500mtr"), (1500, "1.5km"), (2000, "2km")])
|
|
def test_step_format_distance(distance, expected):
|
|
assert Step(distance=distance)._format_distance() == expected # noqa: SLF001
|
|
|
|
|
|
def test_step_str_warmup_and_targets():
|
|
out = str(Step(warmup=True, duration=600, power=Value(value=150, units=ValueUnits.WATTS)))
|
|
assert "Warmup" in out
|
|
assert "- 10m" in out
|
|
assert "150W" in out
|
|
|
|
|
|
def test_step_str_reps_block_renders_children():
|
|
block = Step(reps=3, steps=[Step(duration=60, power=Value(value=100, units=ValueUnits.WATTS))])
|
|
out = str(block)
|
|
assert "3x" in out
|
|
assert "100W" in out
|
|
|
|
|
|
def test_step_str_nested_reps_raises():
|
|
"""A repeat inside a repeat is unsupported and must raise, not silently mis-render."""
|
|
with pytest.raises(ValueError, match="Nested steps not supported"):
|
|
Step(reps=2)._to_str(nested=True) # noqa: SLF001
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SportSettings
|
|
# --------------------------------------------------------------------------- #
|
|
def test_sport_settings_roundtrip():
|
|
assert SportSettings().to_dict() == {}
|
|
assert SportSettings.from_dict({"anything": 1}) == SportSettings()
|
|
assert SportSettings.from_json(SportSettings().to_json()) == SportSettings()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# WorkoutDoc
|
|
# --------------------------------------------------------------------------- #
|
|
def test_workout_doc_uses_camelcase_api_keys():
|
|
doc = WorkoutDoc(
|
|
description="Threshold 3x10",
|
|
sport_settings=SportSettings(),
|
|
zone_times=[600, 300, 120],
|
|
target=WorkoutTarget.POWER,
|
|
pace_units=PaceUnits.MINS_KM,
|
|
steps=[Step(duration=600, power=Value(value=240, units=ValueUnits.WATTS))],
|
|
)
|
|
data = doc.to_dict()
|
|
# API expects camelCase for these two specifically
|
|
assert "sportSettings" in data and "sport_settings" not in data
|
|
assert "zoneTimes" in data and "zone_times" not in data
|
|
assert data["target"] == "POWER"
|
|
assert data["pace_units"] == "MINS_KM"
|
|
assert data["steps"][0]["power"] == {"value": 240, "units": "w"}
|
|
|
|
|
|
def test_workout_doc_roundtrip_dict_and_json():
|
|
doc = WorkoutDoc(
|
|
description="desc",
|
|
duration=1800,
|
|
ftp=266,
|
|
target=WorkoutTarget.AUTO,
|
|
sport_settings=SportSettings(),
|
|
steps=[Step(reps=2, steps=[Step(duration=300, hr=Value(value=160, units=ValueUnits.HR_ZONE))])],
|
|
zone_times=[{"id": 1, "secs": 100}], # zone_times can be objects, not just ints
|
|
options={"pool_length": "25m"},
|
|
locales=["en"],
|
|
)
|
|
assert WorkoutDoc.from_dict(doc.to_dict()) == doc
|
|
assert WorkoutDoc.from_json(doc.to_json()) == doc
|
|
|
|
|
|
def test_workout_doc_from_dict_maps_camelcase_back():
|
|
doc = WorkoutDoc.from_dict(
|
|
{"description": "d", "sportSettings": {}, "zoneTimes": [1, 2], "target": "HR"}
|
|
)
|
|
assert doc.sport_settings == SportSettings()
|
|
assert doc.zone_times == [1, 2]
|
|
assert doc.target is WorkoutTarget.HR
|
|
|
|
|
|
def test_workout_doc_str_includes_description_and_steps():
|
|
doc = WorkoutDoc(description="Endurance", steps=[Step(duration=3600, freeride=True)])
|
|
out = str(doc)
|
|
assert out.startswith("Endurance")
|
|
assert "freeride" in out
|
|
|
|
|
|
def test_workout_doc_json_is_valid_and_minimal():
|
|
"""to_json must emit real JSON with only the set fields."""
|
|
payload = json.loads(WorkoutDoc(description="x", ftp=250).to_json())
|
|
assert payload == {"description": "x", "ftp": 250}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Exhaustive round-trips (exercise every optional-field branch in to/from_dict)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_value_str_no_units():
|
|
assert str(Value(value=42)) == "42"
|
|
|
|
|
|
def test_step_all_fields_roundtrip():
|
|
step = Step(
|
|
text="Main set",
|
|
text_locale={"en": "Main set"},
|
|
duration=600,
|
|
distance=1000.0,
|
|
until_lap_press=True,
|
|
reps=4,
|
|
warmup=True,
|
|
cooldown=True,
|
|
intensity=Intensity.INTERVAL,
|
|
steps=[Step(duration=60, freeride=True)],
|
|
ramp=True,
|
|
freeride=False,
|
|
maxeffort=True,
|
|
power=Value(value=250, units=ValueUnits.WATTS),
|
|
hr=Value(value=160, units=ValueUnits.PERCENT_HR),
|
|
pace=Value(value=300, units=ValueUnits.MINS_KM),
|
|
cadence=Value(value=90, units=ValueUnits.CADENCE),
|
|
hidepower=True,
|
|
_power=Value(value=248, units=ValueUnits.WATTS),
|
|
_hr=Value(value=158, units=ValueUnits.PERCENT_HR),
|
|
_pace=Value(value=298, units=ValueUnits.MINS_KM),
|
|
_distance=995.0,
|
|
)
|
|
assert Step.from_dict(step.to_dict()) == step
|
|
|
|
|
|
def test_step_format_none_paths():
|
|
assert Step()._format_duration() == "" # noqa: SLF001
|
|
assert Step()._format_distance() == "" # noqa: SLF001
|
|
|
|
|
|
def test_step_str_distance_flags_and_text():
|
|
out = str(
|
|
Step(
|
|
cooldown=True,
|
|
distance=500,
|
|
maxeffort=True,
|
|
ramp=True,
|
|
hidepower=True,
|
|
intensity=Intensity.REST,
|
|
hr=Value(value=140, units=ValueUnits.PERCENT_HR),
|
|
pace=Value(value=300, units=ValueUnits.MINS_KM),
|
|
cadence=Value(value=85, units=ValueUnits.CADENCE),
|
|
text="hold steady",
|
|
)
|
|
)
|
|
assert "Cooldown" in out
|
|
assert "500mtr" in out
|
|
assert "maxeffort" in out and "ramp" in out and "hidepower" in out
|
|
assert "intensity=rest" in out
|
|
assert "140% HR" in out
|
|
assert "85rpm Cadence" in out
|
|
assert "hold steady" in out
|
|
|
|
|
|
def test_workout_doc_all_fields_roundtrip():
|
|
doc = WorkoutDoc(
|
|
description="Full",
|
|
description_locale={"en": "Full"},
|
|
duration=3600,
|
|
distance=40000.0,
|
|
ftp=266,
|
|
lthr=174,
|
|
threshold_pace=4.2,
|
|
pace_units=PaceUnits.MINS_MILE,
|
|
sport_settings=SportSettings(),
|
|
category="WORKOUT",
|
|
target=WorkoutTarget.PACE,
|
|
steps=[Step(duration=600, power=Value(value=240, units=ValueUnits.WATTS))],
|
|
zone_times=[600, 300],
|
|
options={"pool_length": "25m"},
|
|
locales=["en", "es"],
|
|
)
|
|
assert WorkoutDoc.from_dict(doc.to_dict()) == doc
|