host.controller_web_editor.tests.test_curves_parity

Python <-> JavaScript parity test for curve evaluation.

Generated by Claude

The web editor ships a JavaScript port of utils/math/curves.py at host/controller_web_editor/web/src/lib/curves.js (plain ESM with JSDoc types). This test runs the JS module under Node and compares its output against the Python implementation point-by-point on a dense input grid.

Skipped if node is not on PATH. Robot CI runs Python only, but every local developer has Node available from the Vite toolchain.

  1"""Python <-> JavaScript parity test for curve evaluation.
  2
  3# Generated by Claude
  4
  5The web editor ships a JavaScript port of `utils/math/curves.py` at
  6`host/controller_web_editor/web/src/lib/curves.js` (plain ESM with JSDoc
  7types).  This test runs the JS module under Node and compares its output
  8against the Python implementation point-by-point on a dense input grid.
  9
 10Skipped if `node` is not on PATH.  Robot CI runs Python only, but every
 11local developer has Node available from the Vite toolchain.
 12"""
 13
 14import json
 15import os
 16import shutil
 17import subprocess
 18import sys
 19import textwrap
 20
 21import pytest
 22
 23
 24_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
 25if _REPO_ROOT not in sys.path:
 26    sys.path.insert(0, _REPO_ROOT)
 27
 28
 29from utils.math.curves import (  # noqa: E402
 30    default_segment_points,
 31    default_spline_points,
 32    evaluate_segments,
 33    evaluate_spline,
 34)
 35
 36
 37_CURVES_JS = os.path.abspath(os.path.join(
 38    os.path.dirname(__file__), "..", "web", "src", "lib", "curves.js",
 39)).replace("\\", "/")
 40
 41
 42_SPLINE_FIXTURES = {
 43    "default_spline": default_spline_points(),
 44    "ramp_spline": [
 45        {"x": -1.0, "y": -1.0, "tangent": 0.5},
 46        {"x": -0.25, "y": -0.05, "tangent": 0.2},
 47        {"x": 0.25, "y": 0.05, "tangent": 0.2},
 48        {"x": 1.0, "y": 1.0, "tangent": 0.5},
 49    ],
 50    "steep_spline": [
 51        {"x": -1.0, "y": -1.0, "tangent": 2.5},
 52        {"x": 0.0, "y": 0.0, "tangent": 0.1},
 53        {"x": 1.0, "y": 1.0, "tangent": 2.5},
 54    ],
 55}
 56
 57_SEGMENT_FIXTURES = {
 58    "default_segment": default_segment_points(),
 59    "deadzone_segment": [
 60        {"x": -1.0, "y": -1.0},
 61        {"x": -0.1, "y": 0.0},
 62        {"x": 0.1, "y": 0.0},
 63        {"x": 1.0, "y": 1.0},
 64    ],
 65    "shifted_segment": [
 66        {"x": -1.0, "y": -0.5},
 67        {"x": 0.2, "y": 0.1},
 68        {"x": 1.0, "y": 0.9},
 69    ],
 70}
 71
 72
 73def _input_grid() -> list[float]:
 74    # 41 samples across a slightly oversized range so we also test clamping.
 75    return [-1.2 + i * 0.06 for i in range(41)]
 76
 77
 78def _node_available() -> bool:
 79    return shutil.which("node") is not None
 80
 81
 82def _run_node(spline_fx: dict, segment_fx: dict, xs: list[float]) -> dict:
 83    """Import curves.js under Node and evaluate the fixtures."""
 84    driver = textwrap.dedent(f"""
 85        import {{ evaluateSpline, evaluateSegments }} from 'file://{_CURVES_JS}';
 86        const splineFx = {json.dumps(spline_fx)};
 87        const segFx = {json.dumps(segment_fx)};
 88        const xs = {json.dumps(xs)};
 89        const out = {{ spline: {{}}, segment: {{}} }};
 90        for (const k of Object.keys(splineFx)) {{
 91          out.spline[k] = xs.map((x) => evaluateSpline(splineFx[k], x));
 92        }}
 93        for (const k of Object.keys(segFx)) {{
 94          out.segment[k] = xs.map((x) => evaluateSegments(segFx[k], x));
 95        }}
 96        process.stdout.write(JSON.stringify(out));
 97    """)
 98    result = subprocess.run(
 99        ["node", "--input-type=module", "-e", driver],
100        capture_output=True, text=True, timeout=30,
101    )
102    if result.returncode != 0:
103        raise RuntimeError(
104            f"node driver failed (exit {result.returncode}):\n"
105            f"stdout={result.stdout}\nstderr={result.stderr}")
106    return json.loads(result.stdout)
107
108
109@pytest.mark.skipif(not _node_available(), reason="node not installed")
110def test_spline_parity():
111    xs = _input_grid()
112    ts_out = _run_node(_SPLINE_FIXTURES, _SEGMENT_FIXTURES, xs)
113    for name, points in _SPLINE_FIXTURES.items():
114        py_values = [evaluate_spline(points, x) for x in xs]
115        js_values = ts_out["spline"][name]
116        for x, py, js in zip(xs, py_values, js_values):
117            assert py == pytest.approx(js, abs=1e-12), (
118                f"{name} mismatch at x={x}: py={py} js={js}")
119
120
121@pytest.mark.skipif(not _node_available(), reason="node not installed")
122def test_segment_parity():
123    xs = _input_grid()
124    ts_out = _run_node(_SPLINE_FIXTURES, _SEGMENT_FIXTURES, xs)
125    for name, points in _SEGMENT_FIXTURES.items():
126        py_values = [evaluate_segments(points, x) for x in xs]
127        js_values = ts_out["segment"][name]
128        for x, py, js in zip(xs, py_values, js_values):
129            assert py == pytest.approx(js, abs=1e-12), (
130                f"{name} mismatch at x={x}: py={py} js={js}")
@pytest.mark.skipif(not _node_available(), reason='node not installed')
def test_spline_parity():
110@pytest.mark.skipif(not _node_available(), reason="node not installed")
111def test_spline_parity():
112    xs = _input_grid()
113    ts_out = _run_node(_SPLINE_FIXTURES, _SEGMENT_FIXTURES, xs)
114    for name, points in _SPLINE_FIXTURES.items():
115        py_values = [evaluate_spline(points, x) for x in xs]
116        js_values = ts_out["spline"][name]
117        for x, py, js in zip(xs, py_values, js_values):
118            assert py == pytest.approx(js, abs=1e-12), (
119                f"{name} mismatch at x={x}: py={py} js={js}")
@pytest.mark.skipif(not _node_available(), reason='node not installed')
def test_segment_parity():
122@pytest.mark.skipif(not _node_available(), reason="node not installed")
123def test_segment_parity():
124    xs = _input_grid()
125    ts_out = _run_node(_SPLINE_FIXTURES, _SEGMENT_FIXTURES, xs)
126    for name, points in _SEGMENT_FIXTURES.items():
127        py_values = [evaluate_segments(points, x) for x in xs]
128        js_values = ts_out["segment"][name]
129        for x, py, js in zip(xs, py_values, js_values):
130            assert py == pytest.approx(js, abs=1e-12), (
131                f"{name} mismatch at x={x}: py={py} js={js}")