e18e05e02c
get_workouts lists the reusable library (client-side folder/sport filters); get_workout renders a single workout including its structured workout_doc steps via a defensive, depth-capped recursive formatter (repeats, ramps, warmup/ cooldown, power/hr/pace targets). New tools/workouts.py; registered. Implements #4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""
|
|
Tests for intervals_mcp_server.tools.workouts (0.3.0 workout library).
|
|
|
|
Covers get_workouts (list + client-side filters) and get_workout (full detail
|
|
with a nested workout_doc), plus empty / error / credential branches.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
from intervals_mcp_server import credentials
|
|
from intervals_mcp_server.credentials import CredentialError
|
|
from intervals_mcp_server.tools import workouts
|
|
|
|
LIBRARY = [
|
|
{"id": 10, "name": "VO2 5x5", "type": "Ride", "icu_training_load": 95, "moving_time": 3600, "folder_id": 1},
|
|
{"id": 11, "name": "Easy run", "type": "Run", "moving_time": 2400, "folder_id": 2},
|
|
]
|
|
|
|
WORKOUT_DETAIL = {
|
|
"id": 10,
|
|
"name": "VO2 5x5",
|
|
"type": "Ride",
|
|
"indoor": True,
|
|
"moving_time": 3600,
|
|
"icu_training_load": 95,
|
|
"description": "VO2max builder",
|
|
"tags": ["vo2", "key"],
|
|
"workout_doc": {
|
|
"steps": [
|
|
{"duration": 900, "power": {"value": 60, "units": "%ftp"}, "warmup": True},
|
|
{
|
|
"reps": 5,
|
|
"steps": [
|
|
{"duration": 300, "power": {"value": 115, "units": "%ftp"}, "text": "hard"},
|
|
{"duration": 300, "power": {"value": 50, "units": "%ftp"}, "text": "easy"},
|
|
],
|
|
},
|
|
{"duration": 600, "power": {"start": 60, "end": 40, "units": "%ftp"}, "cooldown": True},
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
def _patch_request(monkeypatch, result):
|
|
calls: list[dict] = []
|
|
|
|
async def fake(**kwargs):
|
|
calls.append(kwargs)
|
|
return result
|
|
|
|
monkeypatch.setattr(workouts, "make_intervals_request", fake)
|
|
return calls
|
|
|
|
|
|
def test_get_workouts_all(monkeypatch):
|
|
calls = _patch_request(monkeypatch, LIBRARY)
|
|
out = asyncio.run(workouts.get_workouts())
|
|
assert calls[0]["url"] == "/athlete/i1/workouts"
|
|
assert "Workout Library (2)" in out
|
|
assert "VO2 5x5 | Ride (load 95, 3600s, folder 1) [id: 10]" in out
|
|
|
|
|
|
def test_get_workouts_filter_folder(monkeypatch):
|
|
_patch_request(monkeypatch, LIBRARY)
|
|
out = asyncio.run(workouts.get_workouts(folder_id=2))
|
|
assert "Easy run" in out
|
|
assert "VO2 5x5" not in out
|
|
|
|
|
|
def test_get_workouts_filter_sport(monkeypatch):
|
|
_patch_request(monkeypatch, LIBRARY)
|
|
out = asyncio.run(workouts.get_workouts(sport_type="ride"))
|
|
assert "VO2 5x5" in out
|
|
assert "Easy run" not in out
|
|
|
|
|
|
def test_get_workouts_empty(monkeypatch):
|
|
_patch_request(monkeypatch, [])
|
|
assert "No workouts found" in asyncio.run(workouts.get_workouts())
|
|
|
|
|
|
def test_get_workouts_error(monkeypatch):
|
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
|
assert "Error fetching workouts: boom" in asyncio.run(workouts.get_workouts())
|
|
|
|
|
|
def test_get_workout_detail_with_nested_doc(monkeypatch):
|
|
calls = _patch_request(monkeypatch, WORKOUT_DETAIL)
|
|
out = asyncio.run(workouts.get_workout(10))
|
|
assert calls[0]["url"] == "/athlete/i1/workouts/10"
|
|
assert "Workout: VO2 5x5" in out
|
|
assert "Training Load: 95" in out
|
|
assert "Tags: vo2, key" in out
|
|
assert "Steps:" in out
|
|
assert "15m @ 60%ftp (warmup)" in out
|
|
assert "5x:" in out # repeat block rendered
|
|
assert "5m @ 115%ftp — hard" in out # nested step
|
|
assert "10m @ 60-40%ftp (cooldown)" in out # power range (no ramp flag set)
|
|
|
|
|
|
def test_get_workout_ramp_step(monkeypatch):
|
|
_patch_request(
|
|
monkeypatch,
|
|
{
|
|
"id": 12,
|
|
"name": "Ramp test",
|
|
"workout_doc": {
|
|
"steps": [{"ramp": True, "power": {"start": 100, "end": 300, "units": "w"}}]
|
|
},
|
|
},
|
|
)
|
|
out = asyncio.run(workouts.get_workout(12))
|
|
assert "ramp 100-300w" in out
|
|
|
|
|
|
def test_get_workout_not_found(monkeypatch):
|
|
_patch_request(monkeypatch, {})
|
|
assert "No workout found with ID 99" in asyncio.run(workouts.get_workout(99))
|
|
|
|
|
|
def test_get_workout_error(monkeypatch):
|
|
_patch_request(monkeypatch, {"error": True, "message": "nope"})
|
|
assert "Error fetching workout: nope" in asyncio.run(workouts.get_workout(10))
|
|
|
|
|
|
def test_get_workout_credential_error(monkeypatch):
|
|
async def _deny():
|
|
raise CredentialError("not approved")
|
|
|
|
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
|
|
assert "not approved" in asyncio.run(workouts.get_workout(10))
|