Files
mop-skills/skills/zwift-workout/tests/test_zwo.py
T
IT Guy 109162889c feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing)
Bike-only Zwift .zwo generator with human-readable preview + confirmation loop.

- scripts/zwo_builder.py: spec<->.zwo build/validate/parse/render/TSS estimate
- scripts/zwo.py: CLI (build, from-template, validate, parse, render, templates)
- references/zwo-format.md: condensed offline format reference
- assets/example-vo2max.zwo + tests/test_zwo.py (9 tests)
- Publishes via hosting skill; files result into Knowledge Wiki

Format ground truth: h4l/zwift-workout-file-reference
2026-09-20 07:47:05 +00:00

131 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Focused tests for the zwo builder. Run: python3 tests/test_zwo.py (or pytest)."""
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SKILL_DIR / "scripts"))
import zwo_builder as zb # noqa: E402
SPEC = {
"name": "VO2 Max 5x3",
"author": "IT Guy",
"description": "5 × 3 min @ 115 % FTP",
"sportType": "bike",
"tags": ["vo2max"],
"steps": [
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
"onPower": 1.15, "offPower": 0.55},
{"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40},
],
}
def test_build_is_wellformed_and_roundtrips():
xml = zb.spec_to_xml(SPEC)
root = ET.fromstring(xml)
assert root.tag == "workout_file"
assert root.findtext("sportType") == "bike"
workout = root.find("workout")
assert [c.tag for c in workout] == ["Warmup", "IntervalsT", "Cooldown"]
parsed = zb.parse_file(_write_tmp(xml))
assert parsed["name"] == "VO2 Max 5x3"
assert parsed["steps"][1]["repeat"] == 5
assert parsed["steps"][1]["onPower"] == 1.15
def test_power_out_of_range_raises():
bad = dict(SPEC, steps=[{"type": "SteadyState", "duration": 600, "power": 3.0}])
try:
zb.build_spec(bad)
except zb.ZwoValidationError as exc:
assert "implausible" in str(exc)
else:
raise AssertionError("expected ZwoValidationError")
def test_missing_required_field_raises():
bad = dict(SPEC, steps=[{"type": "IntervalsT", "repeat": 5, "onDuration": 180,
"offDuration": 180, "onPower": 1.0}]) # offPower missing
try:
zb.build_spec(bad)
except zb.ZwoValidationError as exc:
assert "offPower" in str(exc)
else:
raise AssertionError("expected ZwoValidationError")
def test_sport_type_bike_only():
bad = dict(SPEC, sportType="run")
try:
zb.build_spec(bad)
except zb.ZwoValidationError as exc:
assert "sportType" in str(exc)
else:
raise AssertionError("expected ZwoValidationError")
def test_escaping_special_chars():
spec = dict(SPEC, name='Push & Pull "hart"', steps=[
{"type": "SteadyState", "duration": 300, "power": 0.9}])
xml = zb.spec_to_xml(spec)
parsed = ET.fromstring(xml)
assert parsed.findtext("name") == 'Push & Pull "hart"'
def test_estimate_tss_and_duration():
est = zb.estimate(SPEC)
assert est["duration_s"] == 600 + 5 * 360 + 300
assert 40 < est["tss"] < 120
assert abs(est["np_frac"] - est["if"]) < 1e-9
def test_templates_available():
assert set(zb.templates()) >= {"endurance", "sweetspot", "vo2max", "sprint"}
spec = zb.from_template("vo2max", "Test")
assert spec["name"] == "Test"
zb.validate_spec(spec)
def test_asset_fixture_valid():
asset = SKILL_DIR / "assets" / "example-vo2max.zwo"
ok, messages = zb.validate_file(str(asset))
assert ok, messages
def test_render_contains_key_lines():
text = zb.render_text(SPEC)
assert "VO2 Max 5x3" in text
assert "IntervalsT" in text
assert "TSS" in text
def _write_tmp(xml: str) -> str:
fd = tempfile.NamedTemporaryFile("w", suffix=".zwo", delete=False, encoding="utf-8")
fd.write(xml)
fd.close()
return fd.name
def _run_all():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for t in tests:
try:
t()
print(f" ✅ {t.__name__}")
except Exception as exc: # noqa: BLE001
failed += 1
print(f" ❌ {t.__name__}: {exc}")
print(f"\n{len(tests) - failed}/{len(tests)} tests passed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(_run_all())