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:
@@ -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())
|
||||
Reference in New Issue
Block a user