feat(wellness): add update_wellness write tool, computed Form (TSB), date-label fix
Add an update_wellness MCP tool that writes nutrition macros, hydration, vitals,
sleep, and subjective ratings to Intervals.icu via PUT /athlete/{id}/wellness/{date}.
Only provided fields are sent; pass -1 to clear a numeric field and locked=True to
stop device/app syncs from overwriting the values. Sleep is taken in hours and
stored as seconds (with -1 passing through as the clear sentinel).
Also surface computed Form (TSB = CTL - ATL) in wellness output, and prefer an
explicit `date` field over the record `id` for the Date label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
This commit is contained in:
@@ -4,6 +4,7 @@ Date: 2025-05-24
|
||||
Training Metrics:
|
||||
- Fitness (CTL): 70.87253
|
||||
- Fatigue (ATL): 91.97159
|
||||
- Form (TSB): -21.1
|
||||
- Ramp Rate: 6.997368
|
||||
- CTL Load: 299
|
||||
- ATL Load: 299
|
||||
|
||||
@@ -64,6 +64,24 @@ def test_format_wellness_entry():
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_format_wellness_entry_computes_form_tsb():
|
||||
"""Form (TSB) is computed as CTL - ATL when both are present."""
|
||||
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50, "atl": 65})
|
||||
assert "Form (TSB): -15.0" in result
|
||||
|
||||
|
||||
def test_format_wellness_entry_no_form_without_both_components():
|
||||
"""Form is omitted if either CTL or ATL is missing."""
|
||||
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50})
|
||||
assert "Form (TSB)" not in result
|
||||
|
||||
|
||||
def test_format_wellness_entry_prefers_explicit_date_over_id():
|
||||
"""An explicit `date` field wins over `id` for the Date line."""
|
||||
result = format_wellness_entry({"id": "2024-06-01", "date": "2024-06-02", "ctl": 50})
|
||||
assert "Date: 2024-06-02" in result
|
||||
|
||||
|
||||
def test_format_wellness_entry_include_all_fields():
|
||||
"""
|
||||
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Tests for intervals_mcp_server.tools.wellness.
|
||||
|
||||
Covers the wellness write tool (``update_wellness``): field mapping to the
|
||||
Intervals.icu camelCase schema, the sleep hours->seconds conversion and the
|
||||
``-1`` clear sentinel, request shape (PUT + path), the empty-payload guard, and
|
||||
the error / credential branches. The default caller credentials come from the
|
||||
autouse fixture in conftest (athlete ``i1``).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from intervals_mcp_server import credentials
|
||||
from intervals_mcp_server.credentials import CredentialError
|
||||
from intervals_mcp_server.tools import wellness
|
||||
|
||||
|
||||
def _patch_request(monkeypatch, result):
|
||||
"""Patch make_intervals_request; capture the call kwargs, return ``result``."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(wellness, "make_intervals_request", fake)
|
||||
return calls
|
||||
|
||||
|
||||
def test_update_wellness_maps_fields_and_puts(monkeypatch):
|
||||
calls = _patch_request(monkeypatch, {"id": "2025-05-24", "weight": 78})
|
||||
out = asyncio.run(
|
||||
wellness.update_wellness(
|
||||
date="2025-05-24",
|
||||
weight=78,
|
||||
resting_hr=50,
|
||||
hrv=65.5,
|
||||
sleep_hours=8,
|
||||
calories_consumed=2200,
|
||||
carbohydrates=300,
|
||||
protein=140,
|
||||
fat=70,
|
||||
hydration_volume=2.5,
|
||||
comments="felt good",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["method"] == "PUT"
|
||||
assert call["url"] == "/athlete/i1/wellness/2025-05-24"
|
||||
assert call["api_key"] == "testkey"
|
||||
|
||||
payload = call["data"]
|
||||
assert payload["weight"] == 78
|
||||
assert payload["restingHR"] == 50
|
||||
assert payload["hrv"] == 65.5
|
||||
assert payload["sleepSecs"] == 8 * 3600 # hours -> seconds
|
||||
assert payload["kcalConsumed"] == 2200
|
||||
assert payload["carbohydrates"] == 300
|
||||
assert payload["protein"] == 140
|
||||
assert payload["fatTotal"] == 70
|
||||
assert payload["hydrationVolume"] == 2.5
|
||||
assert payload["comments"] == "felt good"
|
||||
# Omitted fields must not be sent.
|
||||
assert "mood" not in payload
|
||||
assert "locked" not in payload
|
||||
|
||||
assert "Updated wellness for 2025-05-24" in out
|
||||
|
||||
|
||||
def test_update_wellness_defaults_date_to_today(monkeypatch):
|
||||
calls = _patch_request(monkeypatch, {"id": "today"})
|
||||
asyncio.run(wellness.update_wellness(weight=80))
|
||||
# URL date segment defaults to today's date (YYYY-MM-DD, 10 chars).
|
||||
date_seg = calls[0]["url"].rsplit("/", 1)[1]
|
||||
assert len(date_seg) == 10 and date_seg.count("-") == 2
|
||||
|
||||
|
||||
def test_update_wellness_no_fields_returns_message(monkeypatch):
|
||||
calls = _patch_request(monkeypatch, {})
|
||||
out = asyncio.run(wellness.update_wellness(date="2025-05-24"))
|
||||
assert "No wellness fields provided" in out
|
||||
assert calls == [] # no request made
|
||||
|
||||
|
||||
def test_update_wellness_clear_with_negative_one(monkeypatch):
|
||||
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
|
||||
asyncio.run(wellness.update_wellness(date="2025-05-24", weight=-1, sleep_hours=-1))
|
||||
payload = calls[0]["data"]
|
||||
assert payload["weight"] == -1
|
||||
# -1 is the clear sentinel and must NOT be scaled to -3600.
|
||||
assert payload["sleepSecs"] == -1
|
||||
|
||||
|
||||
def test_update_wellness_locked_false_is_sent(monkeypatch):
|
||||
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
|
||||
asyncio.run(wellness.update_wellness(date="2025-05-24", locked=False))
|
||||
assert calls[0]["data"]["locked"] is False
|
||||
|
||||
|
||||
def test_update_wellness_error_path(monkeypatch):
|
||||
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||
assert "Error updating wellness data: boom" in out
|
||||
|
||||
|
||||
def test_update_wellness_invalid_date(monkeypatch):
|
||||
calls = _patch_request(monkeypatch, {})
|
||||
out = asyncio.run(wellness.update_wellness(date="not-a-date", weight=80))
|
||||
assert out.startswith("Error:")
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_update_wellness_credential_error(monkeypatch):
|
||||
async def _deny():
|
||||
raise CredentialError("not approved")
|
||||
|
||||
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
|
||||
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||
assert "not approved" in out
|
||||
|
||||
|
||||
def test_update_wellness_non_dict_result(monkeypatch):
|
||||
# If the API returns a list (unexpected), we still confirm the write.
|
||||
_patch_request(monkeypatch, [])
|
||||
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||
assert out == "Updated wellness for 2025-05-24."
|
||||
Reference in New Issue
Block a user