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
This commit is contained in:
IT Guy
2026-09-20 07:47:05 +00:00
parent 42f37a89f8
commit 109162889c
6 changed files with 993 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
#!/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())