"""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 == ''
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 == ''
def test_component_with_conditional(self):
comp = '(defcomp ~badge (&key show label) (when show (span label)))'
assert _js_render('(~badge :show true :label "ok")', comp) == 'ok'
assert _js_render('(~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}"