"""Test sx.js string renderer matches Python renderer output. Runs sx.js through Node.js and compares output with Python. """ from __future__ import annotations import json import subprocess from pathlib import Path import pytest from shared.sx.parser import parse, parse_all from shared.sx.html import render as py_render from shared.sx.ref.sx_ref import evaluate SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js" SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-test.js" def _js_render(sx_text: str, components_text: str = "") -> str: """Run sx.js + sx-test.js in Node and return the renderToString result.""" import tempfile, os # Build a small Node script that requires the source files script = f""" globalThis.document = undefined; // no DOM needed for string render // sx.js IIFE uses (typeof window !== "undefined" ? window : this). // In Node file mode, `this` is module.exports, not globalThis. // Patch: make the IIFE target globalThis so Sx is accessible. var _origThis = this; Object.defineProperty(globalThis, 'document', {{ value: undefined, writable: true }}); (function() {{ var _savedThis = globalThis; {SX_JS.read_text()} // Hoist Sx from module.exports to globalThis if needed if (typeof Sx === 'undefined' && typeof module !== 'undefined' && module.exports && module.exports.Sx) {{ globalThis.Sx = module.exports.Sx; }} }}).call(globalThis); {SX_TEST_JS.read_text()} if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)}); var result = Sx.renderToString({json.dumps(sx_text)}); process.stdout.write(result); """ # Write to temp file to avoid OS arg length limits fd, tmp = tempfile.mkstemp(suffix=".js") try: with os.fdopen(fd, "w") as f: f.write(script) result = subprocess.run( ["node", tmp], capture_output=True, text=True, timeout=5, ) finally: os.unlink(tmp) if result.returncode != 0: pytest.fail(f"Node.js error:\n{result.stderr}") return result.stdout class TestParserParity: """Parser produces equivalent structures.""" def test_simple_element(self): assert _js_render('(div "hello")') == '
hello
' def test_nested_elements(self): html = _js_render('(div :class "card" (p "text"))') assert html == '

text

' def test_void_element(self): assert _js_render('(img :src "a.jpg")') == '' assert _js_render('(br)') == '
' def test_boolean_attr(self): assert _js_render('(input :disabled true :type "text")') == '' def test_nil_attr_omitted(self): assert _js_render('(div :class nil "hi")') == '
hi
' def test_false_attr_omitted(self): assert _js_render('(div :class false "hi")') == '
hi
' def test_numbers(self): assert _js_render('(span 42)') == '42' def test_escaping(self): html = _js_render('(div "")') assert "<script>" in html class TestSpecialForms: """Special forms render correctly.""" def test_if_true(self): assert _js_render('(if true (span "yes") (span "no"))') == 'yes' def test_if_false(self): assert _js_render('(if false (span "yes") (span "no"))') == 'no' def test_if_nil(self): assert _js_render('(if nil (span "yes") (span "no"))') == 'no' def test_when_true(self): assert _js_render('(when true (span "yes"))') == 'yes' def test_when_false(self): assert _js_render('(when false (span "yes"))') == '' def test_str(self): assert _js_render('(div (str "a" "b" "c"))') == '
abc
' def test_fragment(self): assert _js_render('(<> (span "a") (span "b"))') == 'ab' def test_let(self): assert _js_render('(let ((x "hello")) (div x))') == '
hello
' def test_let_clojure_style(self): assert _js_render('(let (x "hello" y "world") (div (str x " " y)))') == '
hello world
' def test_and(self): assert _js_render('(when (and true true) (span "ok"))') == 'ok' assert _js_render('(when (and true false) (span "ok"))') == '' def test_or(self): assert _js_render('(div (or nil "fallback"))') == '
fallback
' class TestComponents: """Component definition and rendering.""" CARD = '(defcomp ~card (&key title) (div :class "card" (h2 title)))' def test_simple_component(self): html = _js_render('(~card :title "Hello")', self.CARD) assert html == '

Hello

' def test_component_with_children(self): comp = '(defcomp ~box (&key &rest children) (div :class "box" (raw! children)))' html = _js_render('(~box (p "inside"))', comp) assert html == '

inside

' def test_component_with_conditional(self): comp = '(defcomp ~shared:misc/badge (&key show label) (when show (span label)))' assert _js_render('(~shared:misc/badge :show true :label "ok")', comp) == 'ok' assert _js_render('(~shared:misc/badge :show false :label "ok")', comp) == '' def test_nested_components(self): comps = """ (defcomp ~inner (&key text) (span text)) (defcomp ~outer (&key label) (div (~inner :text label))) """ html = _js_render('(~outer :label "hi")', comps) assert html == '
hi
' class TestClientComponentsTag: """client_components_tag() generates valid sx for JS consumption.""" def test_emits_script_tag(self): from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV # Register a test component register_components('(defcomp ~test-cct (&key label) (span label))') try: tag = client_components_tag("test-cct") assert tag.startswith('') assert "defcomp ~test-cct" in tag finally: _COMPONENT_ENV.pop("~test-cct", None) def test_roundtrip_through_js(self): """Component emitted by client_components_tag renders identically in JS.""" from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV register_components('(defcomp ~test-rt (&key title) (div :class "rt" title))') try: tag = client_components_tag("test-rt") # Extract the sx source from the script tag sx_source = tag.replace('', '') js_html = _js_render('(~test-rt :title "hello")', sx_source) py_html = py_render(parse('(~test-rt :title "hello")'), _COMPONENT_ENV) assert js_html == py_html finally: _COMPONENT_ENV.pop("~test-rt", None) class TestPythonParity: """JS string renderer matches Python renderer output.""" CASES = [ '(div :class "main" (p "hello"))', '(div (if true "yes" "no"))', '(div (when false "hidden"))', '(span (str "a" "-" "b"))', '(<> (div "one") (div "two"))', '(ul (li "a") (li "b") (li "c"))', '(input :type "text" :disabled true :value "x")', '(div :class nil :id "ok" "text")', '(img :src "photo.jpg" :alt "A photo")', '(table (tr (td "cell")))', ] @pytest.mark.parametrize("sx_text", CASES) def test_matches_python(self, sx_text): py_html = py_render(parse(sx_text)) js_html = _js_render(sx_text) assert js_html == py_html, f"Mismatch for {sx_text!r}:\n PY: {py_html!r}\n JS: {js_html!r}" COMP_CASES = [ ( '(defcomp ~tag (&key label colour) (span :class (str "tag-" colour) label))', '(~tag :label "new" :colour "red")', ), ( '(defcomp ~wrap (&key &rest children) (div :class "w" (raw! children)))', '(~wrap (p "a") (p "b"))', ), ] @pytest.mark.parametrize("comp_text,call_text", COMP_CASES) def test_component_matches_python(self, comp_text, call_text): env = {} evaluate(parse(comp_text), env) py_html = py_render(parse(call_text), env) js_html = _js_render(call_text, comp_text) assert js_html == py_html MAP_CASES = [ # map with lambda returning HTML element ( "", '(ul (map (lambda (x) (li x)) ("a" "b" "c")))', ), # map with lambda returning component ( '(defcomp ~item (&key name) (span :class "item" name))', '(div (map (lambda (t) (~item :name (get t "name"))) ({"name" "Alice"} {"name" "Bob"})))', ), # map-indexed with lambda ( "", '(ul (map-indexed (lambda (i x) (li (str i ". " x))) ("foo" "bar")))', ), ] @pytest.mark.parametrize("comp_text,call_text", MAP_CASES) def test_map_lambda_render(self, comp_text, call_text): env = {} if comp_text: for expr in parse_all(comp_text): evaluate(expr, env) py_html = py_render(parse(call_text), env) js_html = _js_render(call_text, comp_text) assert js_html == py_html, f"Mismatch:\n PY: {py_html!r}\n JS: {js_html!r}"