From 32ca059ed738fd64316f04dd1c4e6e2733aef80d Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 00:08:25 +0000 Subject: [PATCH] =?UTF-8?q?Phase=207e:=20isomorphic=20testing=20=E2=80=94?= =?UTF-8?q?=20cross-host=20Python/JS=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- shared/sx/tests/test_isomorphic.py | 313 +++++++++++++++++++++++++++++ sx/sx/plans.sx | 16 +- 2 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 shared/sx/tests/test_isomorphic.py diff --git a/shared/sx/tests/test_isomorphic.py b/shared/sx/tests/test_isomorphic.py new file mode 100644 index 0000000..86f597d --- /dev/null +++ b/shared/sx/tests/test_isomorphic.py @@ -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": "
hello
"}, + {"expr": '(span "text")', "mode": "render", "expected": "text"}, + {"expr": '(p "paragraph")', "mode": "render", "expected": "

paragraph

"}, + + # Attributes + {"expr": '(div :class "box" "content")', "mode": "render", + "expected": '
content
'}, + {"expr": '(a :href "/link" "click")', "mode": "render", + "expected": 'click'}, + {"expr": '(input :type "text" :name "q")', "mode": "render", + "expected": ''}, + + # Nested elements + {"expr": '(div (span "a") (span "b"))', "mode": "render", + "expected": "
ab
"}, + {"expr": '(ul (li "one") (li "two"))', "mode": "render", + "expected": ""}, + + # Void elements (self-closing per spec) + {"expr": '(br)', "mode": "render", "expected": "
"}, + {"expr": '(hr)', "mode": "render", "expected": "
"}, + {"expr": '(img :src "a.png")', "mode": "render", "expected": ''}, + + # Boolean attributes + {"expr": '(input :disabled true)', "mode": "render", "expected": ""}, + {"expr": '(input :disabled false)', "mode": "render", "expected": ""}, + + # Conditional rendering + {"expr": '(div (if true (span "yes") (span "no")))', "mode": "render", + "expected": "
yes
"}, + {"expr": '(div (when true (span "shown")))', "mode": "render", + "expected": "
shown
"}, + {"expr": '(div (when false (span "hidden")))', "mode": "render", + "expected": "
"}, + + # Map in render + {"expr": '(ul (map (fn (x) (li x)) (list "a" "b")))', "mode": "render", + "expected": ""}, + + # Component rendering + {"defs": ['(defcomp ~box (&key title) (div :class "box" title))'], + "expr": '(~box :title "hi")', "mode": "render", + "expected": '
hi
'}, + + {"defs": ['(defcomp ~wrap (&rest children) (section children))'], + "expr": '(~wrap (p "a") (p "b"))', "mode": "render", + "expected": "

a

b

"}, + + {"defs": [ + '(defcomp ~inner (&key x) (em x))', + '(defcomp ~outer (&key label) (div (~inner :x label)))'], + "expr": '(~outer :label "nested")', "mode": "render", + "expected": "
nested
"}, + + # 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": "bold"}, + + {"defs": ['(defcomp ~server-comp (&key x) :affinity :server (em x))'], + "expr": '(~server-comp :x "italic")', "mode": "render", + "expected": "italic"}, + + # HTML escaping + {"expr": '(div "")', "mode": "render", + "expected": "
<script>alert(1)</script>
"}, + {"expr": '(div :title "a&b" "text")', "mode": "render", + "expected": '
text
'}, +] + + +# --------------------------------------------------------------------------- +# 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}" + ) diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index 5c5b742..7bb2e6a 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -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."))