Compare commits
2 Commits
relations
...
2df1014ee3
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df1014ee3 | |||
| e8a991834b |
@@ -396,7 +396,9 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
|
|||||||
parts = []
|
parts = []
|
||||||
for arg in expr[1:]:
|
for arg in expr[1:]:
|
||||||
val = _eval(arg, env)
|
val = _eval(arg, env)
|
||||||
if isinstance(val, str):
|
if isinstance(val, _RawHTML):
|
||||||
|
parts.append(val.html)
|
||||||
|
elif isinstance(val, str):
|
||||||
parts.append(val)
|
parts.append(val)
|
||||||
elif val is not None and val is not NIL:
|
elif val is not None and val is not NIL:
|
||||||
parts.append(str(val))
|
parts.append(str(val))
|
||||||
|
|||||||
175
shared/sexp/tests/test_sexp_js.py
Normal file
175
shared/sexp/tests/test_sexp_js.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Test sexp.js string renderer matches Python renderer output.
|
||||||
|
|
||||||
|
Runs sexp.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.sexp.parser import parse, parse_all
|
||||||
|
from shared.sexp.html import render as py_render
|
||||||
|
from shared.sexp.evaluator import evaluate
|
||||||
|
|
||||||
|
SEXP_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sexp.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _js_render(sexp_text: str, components_text: str = "") -> str:
|
||||||
|
"""Run sexp.js in Node and return the renderToString result."""
|
||||||
|
# Build a small Node script
|
||||||
|
script = f"""
|
||||||
|
global.document = undefined; // no DOM needed for string render
|
||||||
|
{SEXP_JS.read_text()}
|
||||||
|
if ({json.dumps(components_text)}) Sexp.loadComponents({json.dumps(components_text)});
|
||||||
|
var result = Sexp.renderToString({json.dumps(sexp_text)});
|
||||||
|
process.stdout.write(result);
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
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")') == '<div>hello</div>'
|
||||||
|
|
||||||
|
def test_nested_elements(self):
|
||||||
|
html = _js_render('(div :class "card" (p "text"))')
|
||||||
|
assert html == '<div class="card"><p>text</p></div>'
|
||||||
|
|
||||||
|
def test_void_element(self):
|
||||||
|
assert _js_render('(img :src "a.jpg")') == '<img src="a.jpg">'
|
||||||
|
assert _js_render('(br)') == '<br>'
|
||||||
|
|
||||||
|
def test_boolean_attr(self):
|
||||||
|
assert _js_render('(input :disabled true :type "text")') == '<input disabled type="text">'
|
||||||
|
|
||||||
|
def test_nil_attr_omitted(self):
|
||||||
|
assert _js_render('(div :class nil "hi")') == '<div>hi</div>'
|
||||||
|
|
||||||
|
def test_false_attr_omitted(self):
|
||||||
|
assert _js_render('(div :class false "hi")') == '<div>hi</div>'
|
||||||
|
|
||||||
|
def test_numbers(self):
|
||||||
|
assert _js_render('(span 42)') == '<span>42</span>'
|
||||||
|
|
||||||
|
def test_escaping(self):
|
||||||
|
html = _js_render('(div "<script>alert(1)</script>")')
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecialForms:
|
||||||
|
"""Special forms render correctly."""
|
||||||
|
|
||||||
|
def test_if_true(self):
|
||||||
|
assert _js_render('(if true (span "yes") (span "no"))') == '<span>yes</span>'
|
||||||
|
|
||||||
|
def test_if_false(self):
|
||||||
|
assert _js_render('(if false (span "yes") (span "no"))') == '<span>no</span>'
|
||||||
|
|
||||||
|
def test_if_nil(self):
|
||||||
|
assert _js_render('(if nil (span "yes") (span "no"))') == '<span>no</span>'
|
||||||
|
|
||||||
|
def test_when_true(self):
|
||||||
|
assert _js_render('(when true (span "yes"))') == '<span>yes</span>'
|
||||||
|
|
||||||
|
def test_when_false(self):
|
||||||
|
assert _js_render('(when false (span "yes"))') == ''
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
assert _js_render('(div (str "a" "b" "c"))') == '<div>abc</div>'
|
||||||
|
|
||||||
|
def test_fragment(self):
|
||||||
|
assert _js_render('(<> (span "a") (span "b"))') == '<span>a</span><span>b</span>'
|
||||||
|
|
||||||
|
def test_let(self):
|
||||||
|
assert _js_render('(let ((x "hello")) (div x))') == '<div>hello</div>'
|
||||||
|
|
||||||
|
def test_let_clojure_style(self):
|
||||||
|
assert _js_render('(let (x "hello" y "world") (div (str x " " y)))') == '<div>hello world</div>'
|
||||||
|
|
||||||
|
def test_and(self):
|
||||||
|
assert _js_render('(when (and true true) (span "ok"))') == '<span>ok</span>'
|
||||||
|
assert _js_render('(when (and true false) (span "ok"))') == ''
|
||||||
|
|
||||||
|
def test_or(self):
|
||||||
|
assert _js_render('(div (or nil "fallback"))') == '<div>fallback</div>'
|
||||||
|
|
||||||
|
|
||||||
|
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 == '<div class="card"><h2>Hello</h2></div>'
|
||||||
|
|
||||||
|
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 == '<div class="box"><p>inside</p></div>'
|
||||||
|
|
||||||
|
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) == '<span>ok</span>'
|
||||||
|
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 == '<div><span>hi</span></div>'
|
||||||
|
|
||||||
|
|
||||||
|
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("sexp_text", CASES)
|
||||||
|
def test_matches_python(self, sexp_text):
|
||||||
|
py_html = py_render(parse(sexp_text))
|
||||||
|
js_html = _js_render(sexp_text)
|
||||||
|
assert js_html == py_html, f"Mismatch for {sexp_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
|
||||||
1153
shared/static/scripts/sexp.js
Normal file
1153
shared/static/scripts/sexp.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY shared/requirements.txt ./requirements.txt
|
COPY shared/requirements.txt ./requirements.txt
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Node.js for sexp.js parity tests
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install shared deps (includes pytest, pytest-asyncio)
|
# Install shared deps (includes pytest, pytest-asyncio)
|
||||||
COPY shared/requirements.txt ./requirements-shared.txt
|
COPY shared/requirements.txt ./requirements-shared.txt
|
||||||
RUN pip install --no-cache-dir -r requirements-shared.txt pytest-watch
|
RUN pip install --no-cache-dir -r requirements-shared.txt pytest-watch
|
||||||
|
|||||||
Reference in New Issue
Block a user