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
131 lines
3.9 KiB
Python
131 lines
3.9 KiB
Python
#!/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()) |