#!/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())