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}")