Phase 7e: isomorphic testing — cross-host Python/JS comparison
61 tests evaluate the same SX expressions on both Python (sx_ref.py) and JS (sx-browser.js via Node.js), comparing output: - 37 eval tests (arithmetic, strings, collections, logic, HO forms) - 24 render tests (elements, attrs, void elements, components, escaping) All pass — both hosts produce identical results from the same spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
313
shared/sx/tests/test_isomorphic.py
Normal file
313
shared/sx/tests/test_isomorphic.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Isomorphic tests — same SX expressions evaluated on Python and JS.
|
||||
|
||||
Phase 7e: verifies that the bootstrapped evaluator produces identical
|
||||
output across both hosts. Runs each test case through:
|
||||
1. Python sx_ref.py (bootstrapped from spec)
|
||||
2. Node.js sx-browser.js (bootstrapped from spec)
|
||||
|
||||
Compares rendered HTML output to catch any host-specific divergence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from shared.sx.parser import parse
|
||||
from shared.sx.ref import sx_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_SX_BROWSER = os.path.abspath(os.path.join(_HERE, "..", "..", "static", "scripts", "sx-browser.js"))
|
||||
|
||||
def _node_harness(sx_path: str) -> str:
|
||||
return f"""
|
||||
const Sx = require({json.dumps(sx_path)});
|
||||
let input = "";
|
||||
process.stdin.on("data", d => input += d);
|
||||
process.stdin.on("end", () => {{
|
||||
const cases = JSON.parse(input);
|
||||
const results = [];
|
||||
for (const c of cases) {{
|
||||
try {{
|
||||
const env = {{}};
|
||||
if (c.defs) {{
|
||||
for (const d of c.defs) {{
|
||||
Sx.eval(Sx.parse(d)[0], env);
|
||||
}}
|
||||
}}
|
||||
const parsed = Sx.parse(c.expr)[0];
|
||||
let result;
|
||||
if (c.mode === "render") {{
|
||||
result = Sx.renderToHtml(parsed, env);
|
||||
}} else {{
|
||||
const val = Sx.eval(parsed, env);
|
||||
result = typeof val === "object" && val !== null && val._nil ? "nil" : String(val);
|
||||
}}
|
||||
results.push({{ ok: true, result: result }});
|
||||
}} catch (e) {{
|
||||
results.push({{ ok: false, error: e.message || String(e) }});
|
||||
}}
|
||||
}}
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
}});
|
||||
"""
|
||||
|
||||
|
||||
def _run_js(cases: list[dict]) -> list[dict]:
|
||||
"""Run test cases through Node.js sx-browser.js."""
|
||||
harness = _node_harness(_SX_BROWSER)
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness],
|
||||
input=json.dumps(cases),
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Node.js failed: {result.stderr}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _normalize_val(val: str) -> str:
|
||||
"""Normalize value representations across hosts."""
|
||||
# Python True/False → lowercase
|
||||
if val == "True":
|
||||
return "true"
|
||||
if val == "False":
|
||||
return "false"
|
||||
# Python float 5.0 → 5 (match JS integer division)
|
||||
try:
|
||||
f = float(val)
|
||||
if f == int(f):
|
||||
return str(int(f))
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
# Python list repr [1, 2, 3] → 1,2,3 (match JS Array.toString)
|
||||
if val.startswith("[") and val.endswith("]"):
|
||||
inner = val[1:-1]
|
||||
return inner.replace(", ", ",")
|
||||
return val
|
||||
|
||||
|
||||
def _run_py(cases: list[dict]) -> list[dict]:
|
||||
"""Run test cases through Python sx_ref.py."""
|
||||
results = []
|
||||
for c in cases:
|
||||
try:
|
||||
env = {}
|
||||
if c.get("defs"):
|
||||
for d in c["defs"]:
|
||||
sx_ref.evaluate(parse(d), env)
|
||||
parsed = parse(c["expr"])
|
||||
if c.get("mode") == "render":
|
||||
result = sx_ref.render(parsed, env)
|
||||
else:
|
||||
val = sx_ref.evaluate(parsed, env)
|
||||
from shared.sx.types import NIL as SX_NIL
|
||||
result = "nil" if val is SX_NIL or val is None else _normalize_val(str(val))
|
||||
results.append({"ok": True, "result": result})
|
||||
except Exception as e:
|
||||
results.append({"ok": False, "error": str(e)})
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EVAL_CASES = [
|
||||
# Arithmetic
|
||||
{"expr": "(+ 1 2)", "expected": "3"},
|
||||
{"expr": "(* 3 4)", "expected": "12"},
|
||||
{"expr": "(- 10 3)", "expected": "7"},
|
||||
{"expr": "(/ 10 2)", "expected": "5"},
|
||||
|
||||
# Comparison
|
||||
{"expr": "(> 3 2)", "expected": "true"},
|
||||
{"expr": "(< 1 2)", "expected": "true"},
|
||||
{"expr": "(= 5 5)", "expected": "true"},
|
||||
{"expr": '(= "a" "a")', "expected": "true"},
|
||||
|
||||
# String ops
|
||||
{"expr": '(str "hello" " " "world")', "expected": "hello world"},
|
||||
{"expr": '(upper "hello")', "expected": "HELLO"},
|
||||
{"expr": '(lower "WORLD")', "expected": "world"},
|
||||
{"expr": '(join "-" (list "a" "b" "c"))', "expected": "a-b-c"},
|
||||
{"expr": '(len "hello")', "expected": "5"},
|
||||
{"expr": '(trim " hi ")', "expected": "hi"},
|
||||
|
||||
# Collections
|
||||
{"expr": "(len (list 1 2 3))", "expected": "3"},
|
||||
{"expr": "(first (list 10 20 30))", "expected": "10"},
|
||||
{"expr": "(last (list 10 20 30))", "expected": "30"},
|
||||
{"expr": "(nth (list 10 20 30) 1)", "expected": "20"},
|
||||
{"expr": "(contains? (list 1 2 3) 2)", "expected": "true"},
|
||||
{"expr": "(empty? (list))", "expected": "true"},
|
||||
|
||||
# Logic
|
||||
{"expr": "(if true 1 2)", "expected": "1"},
|
||||
{"expr": "(if false 1 2)", "expected": "2"},
|
||||
{"expr": "(and true true)", "expected": "true"},
|
||||
{"expr": "(or false true)", "expected": "true"},
|
||||
{"expr": "(not false)", "expected": "true"},
|
||||
|
||||
# Let / lambda
|
||||
{"expr": "(let ((x 10) (y 20)) (+ x y))", "expected": "30"},
|
||||
{"expr": "((fn (x) (* x x)) 5)", "expected": "25"},
|
||||
|
||||
# Higher-order
|
||||
{"expr": "(map (fn (x) (* x x)) (list 1 2 3))", "expected": "1,4,9"},
|
||||
{"expr": "(filter (fn (x) (> x 2)) (list 1 2 3 4))", "expected": "3,4"},
|
||||
{"expr": "(reduce (fn (a x) (+ a x)) 0 (list 1 2 3))", "expected": "6"},
|
||||
{"expr": "(some (fn (x) (> x 3)) (list 1 2 5))", "expected": "true"},
|
||||
{"expr": "(every? (fn (x) (> x 0)) (list 1 2 3))", "expected": "true"},
|
||||
|
||||
# Dict
|
||||
{"expr": '(get {:a 1 :b 2} :a)', "expected": "1"},
|
||||
{"expr": '(len {:x 10 :y 20})', "expected": "2"},
|
||||
|
||||
# Keywords
|
||||
{"expr": ":hello", "expected": "hello"},
|
||||
|
||||
# Cond
|
||||
{"expr": '(cond (= 1 2) "a" (= 1 1) "b" :else "c")', "expected": "b"},
|
||||
|
||||
# Case
|
||||
{"expr": '(let ((x 2)) (case x 1 "one" 2 "two" "other"))', "expected": "two"},
|
||||
]
|
||||
|
||||
RENDER_CASES = [
|
||||
# Basic elements
|
||||
{"expr": '(div "hello")', "mode": "render", "expected": "<div>hello</div>"},
|
||||
{"expr": '(span "text")', "mode": "render", "expected": "<span>text</span>"},
|
||||
{"expr": '(p "paragraph")', "mode": "render", "expected": "<p>paragraph</p>"},
|
||||
|
||||
# Attributes
|
||||
{"expr": '(div :class "box" "content")', "mode": "render",
|
||||
"expected": '<div class="box">content</div>'},
|
||||
{"expr": '(a :href "/link" "click")', "mode": "render",
|
||||
"expected": '<a href="/link">click</a>'},
|
||||
{"expr": '(input :type "text" :name "q")', "mode": "render",
|
||||
"expected": '<input type="text" name="q" />'},
|
||||
|
||||
# Nested elements
|
||||
{"expr": '(div (span "a") (span "b"))', "mode": "render",
|
||||
"expected": "<div><span>a</span><span>b</span></div>"},
|
||||
{"expr": '(ul (li "one") (li "two"))', "mode": "render",
|
||||
"expected": "<ul><li>one</li><li>two</li></ul>"},
|
||||
|
||||
# Void elements (self-closing per spec)
|
||||
{"expr": '(br)', "mode": "render", "expected": "<br />"},
|
||||
{"expr": '(hr)', "mode": "render", "expected": "<hr />"},
|
||||
{"expr": '(img :src "a.png")', "mode": "render", "expected": '<img src="a.png" />'},
|
||||
|
||||
# Boolean attributes
|
||||
{"expr": '(input :disabled true)', "mode": "render", "expected": "<input disabled />"},
|
||||
{"expr": '(input :disabled false)', "mode": "render", "expected": "<input />"},
|
||||
|
||||
# Conditional rendering
|
||||
{"expr": '(div (if true (span "yes") (span "no")))', "mode": "render",
|
||||
"expected": "<div><span>yes</span></div>"},
|
||||
{"expr": '(div (when true (span "shown")))', "mode": "render",
|
||||
"expected": "<div><span>shown</span></div>"},
|
||||
{"expr": '(div (when false (span "hidden")))', "mode": "render",
|
||||
"expected": "<div></div>"},
|
||||
|
||||
# Map in render
|
||||
{"expr": '(ul (map (fn (x) (li x)) (list "a" "b")))', "mode": "render",
|
||||
"expected": "<ul><li>a</li><li>b</li></ul>"},
|
||||
|
||||
# Component rendering
|
||||
{"defs": ['(defcomp ~box (&key title) (div :class "box" title))'],
|
||||
"expr": '(~box :title "hi")', "mode": "render",
|
||||
"expected": '<div class="box">hi</div>'},
|
||||
|
||||
{"defs": ['(defcomp ~wrap (&rest children) (section children))'],
|
||||
"expr": '(~wrap (p "a") (p "b"))', "mode": "render",
|
||||
"expected": "<section><p>a</p><p>b</p></section>"},
|
||||
|
||||
{"defs": [
|
||||
'(defcomp ~inner (&key x) (em x))',
|
||||
'(defcomp ~outer (&key label) (div (~inner :x label)))'],
|
||||
"expr": '(~outer :label "nested")', "mode": "render",
|
||||
"expected": "<div><em>nested</em></div>"},
|
||||
|
||||
# Component with affinity (should render identically regardless)
|
||||
{"defs": ['(defcomp ~client-comp (&key x) :affinity :client (strong x))'],
|
||||
"expr": '(~client-comp :x "bold")', "mode": "render",
|
||||
"expected": "<strong>bold</strong>"},
|
||||
|
||||
{"defs": ['(defcomp ~server-comp (&key x) :affinity :server (em x))'],
|
||||
"expr": '(~server-comp :x "italic")', "mode": "render",
|
||||
"expected": "<em>italic</em>"},
|
||||
|
||||
# HTML escaping
|
||||
{"expr": '(div "<script>alert(1)</script>")', "mode": "render",
|
||||
"expected": "<div><script>alert(1)</script></div>"},
|
||||
{"expr": '(div :title "a&b" "text")', "mode": "render",
|
||||
"expected": '<div title="a&b">text</div>'},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsomorphicEval:
|
||||
"""Eval cases: same expression → same string result on both hosts."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def js_results(self):
|
||||
return _run_js(EVAL_CASES)
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def py_results(self):
|
||||
return _run_py(EVAL_CASES)
|
||||
|
||||
@pytest.mark.parametrize("idx", range(len(EVAL_CASES)))
|
||||
def test_eval(self, idx, js_results, py_results):
|
||||
case = EVAL_CASES[idx]
|
||||
py = py_results[idx]
|
||||
js = js_results[idx]
|
||||
assert py["ok"], f"Python failed: {py.get('error')}"
|
||||
assert js["ok"], f"JS failed: {js.get('error')}"
|
||||
assert py["result"] == js["result"], (
|
||||
f"Divergence in {case['expr']!r}: "
|
||||
f"Python={py['result']!r}, JS={js['result']!r}"
|
||||
)
|
||||
if "expected" in case:
|
||||
assert py["result"] == case["expected"], (
|
||||
f"Wrong result for {case['expr']!r}: got {py['result']!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestIsomorphicRender:
|
||||
"""Render cases: same expression → same HTML on both hosts."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def js_results(self):
|
||||
return _run_js(RENDER_CASES)
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def py_results(self):
|
||||
return _run_py(RENDER_CASES)
|
||||
|
||||
@pytest.mark.parametrize("idx", range(len(RENDER_CASES)))
|
||||
def test_render(self, idx, js_results, py_results):
|
||||
case = RENDER_CASES[idx]
|
||||
py = py_results[idx]
|
||||
js = js_results[idx]
|
||||
assert py["ok"], f"Python failed: {py.get('error')}"
|
||||
assert js["ok"], f"JS failed: {js.get('error')}"
|
||||
assert py["result"] == js["result"], (
|
||||
f"Divergence in {case['expr']!r}: "
|
||||
f"Python={py['result']!r}, JS={js['result']!r}"
|
||||
)
|
||||
if "expected" in case:
|
||||
assert py["result"] == case["expected"], (
|
||||
f"Wrong result for {case['expr']!r}: "
|
||||
f"expected {case['expected']!r}, got {py['result']!r}"
|
||||
)
|
||||
@@ -2046,7 +2046,21 @@
|
||||
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
|
||||
|
||||
(~doc-subsection :title "7e. Isomorphic Testing"
|
||||
(p "Evaluate same component on Python and JS, compare output. Extends existing test_sx_ref.py cross-evaluator comparison."))
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Cross-host test suite: same SX expressions evaluated on Python (sx_ref.py) and JS (sx-browser.js via Node.js), HTML output compared."))
|
||||
|
||||
(p "61 isomorphic tests verify that Python and JS produce identical results:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "37 eval tests: arithmetic, comparison, strings, collections, logic, let/lambda, higher-order, dict, keywords, cond/case")
|
||||
(li "24 render tests: elements, attributes, nesting, void elements, boolean attrs, conditionals, map, components, affinity, HTML escaping"))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/tests/test_isomorphic.py — cross-host test suite")
|
||||
(li "Run: " (code "python3 -m pytest shared/sx/tests/test_isomorphic.py -q")))))
|
||||
|
||||
(~doc-subsection :title "7f. Universal Page Descriptor"
|
||||
(p "defpage is portable: server executes via execute_page(), client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment."))
|
||||
|
||||
Reference in New Issue
Block a user