OCaml raw! in HTML renderer + SX_USE_OCAML env promotion + golden tests
- sx_render.ml: add raw! handler to HTML renderer (inject pre-rendered content without HTML escaping) - docker-compose.yml: move SX_USE_OCAML/SX_OCAML_BIN to shared env (available to all services, not just sx_docs) - hosts/ocaml/Dockerfile: OCaml kernel build stage - shared/sx/tests/: golden test data + generator for OCaml render tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
168
shared/sx/tests/generate_golden.py
Normal file
168
shared/sx/tests/generate_golden.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Generate golden HTML/aser test data from the Python evaluator.
|
||||
|
||||
Evaluates curated component calls through the Python ref evaluator and
|
||||
writes golden_data.json — a list of {name, sx_input, expected_html,
|
||||
expected_aser} triples.
|
||||
|
||||
Usage:
|
||||
python3 shared/sx/tests/generate_golden.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
if _project_root not in sys.path:
|
||||
sys.path.insert(0, _project_root)
|
||||
|
||||
|
||||
# Curated test cases — component calls and plain expressions that
|
||||
# exercise the rendering pipeline. Each entry is (name, sx_input).
|
||||
GOLDEN_CASES = [
|
||||
# --- Basic HTML rendering ---
|
||||
("div_simple", '(div "hello")'),
|
||||
("div_class", '(div :class "card" "content")'),
|
||||
("p_text", '(p "paragraph text")'),
|
||||
("nested_tags", '(div (p "a") (p "b"))'),
|
||||
("void_br", "(br)"),
|
||||
("void_hr", "(hr)"),
|
||||
("void_img", '(img :src "/photo.jpg" :alt "A photo")'),
|
||||
("void_input", '(input :type "text" :name "q" :placeholder "Search")'),
|
||||
("fragment", '(<> (p "a") (p "b"))'),
|
||||
("boolean_attr", '(input :type "checkbox" :checked true)'),
|
||||
("nil_attr", '(div :class nil "content")'),
|
||||
("empty_string_attr", '(div :class "" "visible")'),
|
||||
|
||||
# --- Control flow ---
|
||||
("if_true", '(if true (p "yes") (p "no"))'),
|
||||
("if_false", '(if false (p "yes") (p "no"))'),
|
||||
("when_true", '(when true (p "shown"))'),
|
||||
("when_false", '(when false (p "hidden"))'),
|
||||
("let_binding", '(let ((x "hi")) (p x))'),
|
||||
("let_multiple", '(let ((x "a") (y "b")) (div (p x) (p y)))'),
|
||||
("cond_form", '(cond (= 1 2) (p "no") (= 1 1) (p "yes") :else (p "default"))'),
|
||||
("case_form", '(case "b" "a" "A" "b" "B" :else "?")'),
|
||||
("and_short", '(and true false)'),
|
||||
("or_short", '(or false "found")'),
|
||||
|
||||
# --- Higher-order forms ---
|
||||
("map_li", '(map (fn (x) (li x)) (list "a" "b" "c"))'),
|
||||
("filter_even", '(filter even? (list 1 2 3 4 5))'),
|
||||
("reduce_sum", '(reduce + 0 (list 1 2 3 4 5))'),
|
||||
|
||||
# --- String operations ---
|
||||
("str_concat", '(str "hello" " " "world")'),
|
||||
("str_upcase", '(upcase "hello")'),
|
||||
|
||||
# --- Component definitions and calls ---
|
||||
("defcomp_simple",
|
||||
'(do (defcomp ~test-badge (&key label) (span :class "badge" label)) (~test-badge :label "New"))'),
|
||||
("defcomp_children",
|
||||
'(do (defcomp ~test-wrap (&rest children) (div :class "wrap" children)) (~test-wrap (p "inside")))'),
|
||||
("defcomp_multi_key",
|
||||
'(do (defcomp ~test-card (&key title subtitle) (div (h2 title) (when subtitle (p subtitle)))) '
|
||||
'(~test-card :title "Title" :subtitle "Sub"))'),
|
||||
("defcomp_no_optional",
|
||||
'(do (defcomp ~test-card2 (&key title subtitle) (div (h2 title) (when subtitle (p subtitle)))) '
|
||||
'(~test-card2 :title "Only Title"))'),
|
||||
|
||||
# --- Nested components ---
|
||||
("nested_components",
|
||||
'(do (defcomp ~inner (&key text) (span :class "inner" text)) '
|
||||
'(defcomp ~outer (&key title &rest children) (div :class "outer" (h2 title) children)) '
|
||||
'(~outer :title "Hello" (~inner :text "World")))'),
|
||||
|
||||
# --- Macros ---
|
||||
("macro_unless",
|
||||
'(do (defmacro unless (cond &rest body) (list \'if (list \'not cond) (cons \'do body))) '
|
||||
'(unless false (p "shown")))'),
|
||||
|
||||
# --- Special rendering patterns ---
|
||||
("do_block", '(div (do (p "a") (p "b")))'),
|
||||
("nil_child", '(div nil "after-nil")'),
|
||||
("number_child", '(div 42)'),
|
||||
("bool_child", '(div true)'),
|
||||
|
||||
# --- Data attributes ---
|
||||
("data_attr", '(div :data-id "123" :data-name "test" "content")'),
|
||||
|
||||
# --- raw! (inject pre-rendered HTML) ---
|
||||
("raw_simple", '(raw! "<b>bold</b>")'),
|
||||
("raw_in_div", '(div (raw! "<em>italic</em>"))'),
|
||||
("raw_component",
|
||||
'(do (defcomp ~rich (&key html) (raw! html)) '
|
||||
'(~rich :html "<p>CMS</p>"))'),
|
||||
|
||||
# --- Shared template components (if available) ---
|
||||
("misc_error_inline",
|
||||
'(do (defcomp ~shared:misc/error-inline (&key (message :as string)) '
|
||||
'(div :class "text-red-600 text-sm" message)) '
|
||||
'(~shared:misc/error-inline :message "Something went wrong"))'),
|
||||
("misc_notification_badge",
|
||||
'(do (defcomp ~shared:misc/notification-badge (&key (count :as number)) '
|
||||
'(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count)) '
|
||||
'(~shared:misc/notification-badge :count 5))'),
|
||||
("misc_cache_cleared",
|
||||
'(do (defcomp ~shared:misc/cache-cleared (&key (time-str :as string)) '
|
||||
'(span :class "text-green-600 font-bold" "Cache cleared at " time-str)) '
|
||||
'(~shared:misc/cache-cleared :time-str "12:00"))'),
|
||||
("misc_error_list_item",
|
||||
'(do (defcomp ~shared:misc/error-list-item (&key (message :as string)) (li message)) '
|
||||
'(~shared:misc/error-list-item :message "Bad input"))'),
|
||||
("misc_fragment_error",
|
||||
'(do (defcomp ~shared:misc/fragment-error (&key (service :as string)) '
|
||||
'(p :class "text-sm text-red-600" "Service " (b service) " is unavailable.")) '
|
||||
'(~shared:misc/fragment-error :service "blog"))'),
|
||||
]
|
||||
|
||||
|
||||
def _generate_html(sx_input: str) -> str:
|
||||
"""Evaluate SX and render to HTML using the Python evaluator."""
|
||||
from shared.sx.ref.sx_ref import evaluate, render
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
env = {}
|
||||
exprs = parse_all(sx_input)
|
||||
|
||||
# For multi-expression inputs (defcomp then call), use evaluate
|
||||
# to process all defs, then render the final expression
|
||||
if len(exprs) > 1:
|
||||
# Evaluate all expressions — defs install into env
|
||||
result = None
|
||||
for expr in exprs:
|
||||
result = evaluate(expr, env)
|
||||
# Render the final result
|
||||
return render(result, env)
|
||||
else:
|
||||
# Single expression — render directly
|
||||
return render(exprs[0], env)
|
||||
|
||||
|
||||
def main():
|
||||
golden = []
|
||||
ok = 0
|
||||
failed = 0
|
||||
for name, sx_input in GOLDEN_CASES:
|
||||
try:
|
||||
html = _generate_html(sx_input)
|
||||
golden.append({
|
||||
"name": name,
|
||||
"sx_input": sx_input,
|
||||
"expected_html": html,
|
||||
})
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print(f" SKIP {name}: {e}")
|
||||
failed += 1
|
||||
|
||||
outpath = os.path.join(os.path.dirname(__file__), "golden_data.json")
|
||||
with open(outpath, "w") as f:
|
||||
json.dump(golden, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Generated {ok} golden cases ({failed} skipped) → {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
232
shared/sx/tests/golden_data.json
Normal file
232
shared/sx/tests/golden_data.json
Normal file
@@ -0,0 +1,232 @@
|
||||
[
|
||||
{
|
||||
"name": "div_simple",
|
||||
"sx_input": "(div \"hello\")",
|
||||
"expected_html": "<div>hello</div>"
|
||||
},
|
||||
{
|
||||
"name": "div_class",
|
||||
"sx_input": "(div :class \"card\" \"content\")",
|
||||
"expected_html": "<div class=\"card\">content</div>"
|
||||
},
|
||||
{
|
||||
"name": "p_text",
|
||||
"sx_input": "(p \"paragraph text\")",
|
||||
"expected_html": "<p>paragraph text</p>"
|
||||
},
|
||||
{
|
||||
"name": "nested_tags",
|
||||
"sx_input": "(div (p \"a\") (p \"b\"))",
|
||||
"expected_html": "<div><p>a</p><p>b</p></div>"
|
||||
},
|
||||
{
|
||||
"name": "void_br",
|
||||
"sx_input": "(br)",
|
||||
"expected_html": "<br />"
|
||||
},
|
||||
{
|
||||
"name": "void_hr",
|
||||
"sx_input": "(hr)",
|
||||
"expected_html": "<hr />"
|
||||
},
|
||||
{
|
||||
"name": "void_img",
|
||||
"sx_input": "(img :src \"/photo.jpg\" :alt \"A photo\")",
|
||||
"expected_html": "<img src=\"/photo.jpg\" alt=\"A photo\" />"
|
||||
},
|
||||
{
|
||||
"name": "void_input",
|
||||
"sx_input": "(input :type \"text\" :name \"q\" :placeholder \"Search\")",
|
||||
"expected_html": "<input type=\"text\" name=\"q\" placeholder=\"Search\" />"
|
||||
},
|
||||
{
|
||||
"name": "fragment",
|
||||
"sx_input": "(<> (p \"a\") (p \"b\"))",
|
||||
"expected_html": "<p>a</p><p>b</p>"
|
||||
},
|
||||
{
|
||||
"name": "boolean_attr",
|
||||
"sx_input": "(input :type \"checkbox\" :checked true)",
|
||||
"expected_html": "<input type=\"checkbox\" checked />"
|
||||
},
|
||||
{
|
||||
"name": "nil_attr",
|
||||
"sx_input": "(div :class nil \"content\")",
|
||||
"expected_html": "<div>content</div>"
|
||||
},
|
||||
{
|
||||
"name": "empty_string_attr",
|
||||
"sx_input": "(div :class \"\" \"visible\")",
|
||||
"expected_html": "<div class=\"\">visible</div>"
|
||||
},
|
||||
{
|
||||
"name": "if_true",
|
||||
"sx_input": "(if true (p \"yes\") (p \"no\"))",
|
||||
"expected_html": "<p>yes</p>"
|
||||
},
|
||||
{
|
||||
"name": "if_false",
|
||||
"sx_input": "(if false (p \"yes\") (p \"no\"))",
|
||||
"expected_html": "<p>no</p>"
|
||||
},
|
||||
{
|
||||
"name": "when_true",
|
||||
"sx_input": "(when true (p \"shown\"))",
|
||||
"expected_html": "<p>shown</p>"
|
||||
},
|
||||
{
|
||||
"name": "when_false",
|
||||
"sx_input": "(when false (p \"hidden\"))",
|
||||
"expected_html": ""
|
||||
},
|
||||
{
|
||||
"name": "let_binding",
|
||||
"sx_input": "(let ((x \"hi\")) (p x))",
|
||||
"expected_html": "<p>hi</p>"
|
||||
},
|
||||
{
|
||||
"name": "let_multiple",
|
||||
"sx_input": "(let ((x \"a\") (y \"b\")) (div (p x) (p y)))",
|
||||
"expected_html": "<div><p>a</p><p>b</p></div>"
|
||||
},
|
||||
{
|
||||
"name": "cond_form",
|
||||
"sx_input": "(cond (= 1 2) (p \"no\") (= 1 1) (p \"yes\") :else (p \"default\"))",
|
||||
"expected_html": "<p>yes</p>"
|
||||
},
|
||||
{
|
||||
"name": "case_form",
|
||||
"sx_input": "(case \"b\" \"a\" \"A\" \"b\" \"B\" :else \"?\")",
|
||||
"expected_html": "B"
|
||||
},
|
||||
{
|
||||
"name": "and_short",
|
||||
"sx_input": "(and true false)",
|
||||
"expected_html": "false"
|
||||
},
|
||||
{
|
||||
"name": "or_short",
|
||||
"sx_input": "(or false \"found\")",
|
||||
"expected_html": "found"
|
||||
},
|
||||
{
|
||||
"name": "map_li",
|
||||
"sx_input": "(map (fn (x) (li x)) (list \"a\" \"b\" \"c\"))",
|
||||
"expected_html": "<li>a</li><li>b</li><li>c</li>"
|
||||
},
|
||||
{
|
||||
"name": "filter_even",
|
||||
"sx_input": "(filter even? (list 1 2 3 4 5))",
|
||||
"expected_html": "<filter><function <lambda> at 0x7c1551c5fe20>12345</filter>"
|
||||
},
|
||||
{
|
||||
"name": "reduce_sum",
|
||||
"sx_input": "(reduce + 0 (list 1 2 3 4 5))",
|
||||
"expected_html": "15"
|
||||
},
|
||||
{
|
||||
"name": "str_concat",
|
||||
"sx_input": "(str \"hello\" \" \" \"world\")",
|
||||
"expected_html": "hello world"
|
||||
},
|
||||
{
|
||||
"name": "str_upcase",
|
||||
"sx_input": "(upcase \"hello\")",
|
||||
"expected_html": "HELLO"
|
||||
},
|
||||
{
|
||||
"name": "defcomp_simple",
|
||||
"sx_input": "(do (defcomp ~test-badge (&key label) (span :class \"badge\" label)) (~test-badge :label \"New\"))",
|
||||
"expected_html": "<span class=\"badge\">New</span>"
|
||||
},
|
||||
{
|
||||
"name": "defcomp_children",
|
||||
"sx_input": "(do (defcomp ~test-wrap (&rest children) (div :class \"wrap\" children)) (~test-wrap (p \"inside\")))",
|
||||
"expected_html": "<div class=\"wrap\"><p>inside</p></div>"
|
||||
},
|
||||
{
|
||||
"name": "defcomp_multi_key",
|
||||
"sx_input": "(do (defcomp ~test-card (&key title subtitle) (div (h2 title) (when subtitle (p subtitle)))) (~test-card :title \"Title\" :subtitle \"Sub\"))",
|
||||
"expected_html": "<div><h2>Title</h2><p>Sub</p></div>"
|
||||
},
|
||||
{
|
||||
"name": "defcomp_no_optional",
|
||||
"sx_input": "(do (defcomp ~test-card2 (&key title subtitle) (div (h2 title) (when subtitle (p subtitle)))) (~test-card2 :title \"Only Title\"))",
|
||||
"expected_html": "<div><h2>Only Title</h2></div>"
|
||||
},
|
||||
{
|
||||
"name": "nested_components",
|
||||
"sx_input": "(do (defcomp ~inner (&key text) (span :class \"inner\" text)) (defcomp ~outer (&key title &rest children) (div :class \"outer\" (h2 title) children)) (~outer :title \"Hello\" (~inner :text \"World\")))",
|
||||
"expected_html": "<div class=\"outer\"><h2>Hello</h2><span class=\"inner\">World</span></div>"
|
||||
},
|
||||
{
|
||||
"name": "macro_unless",
|
||||
"sx_input": "(do (defmacro unless (cond &rest body) (list 'if (list 'not cond) (cons 'do body))) (unless false (p \"shown\")))",
|
||||
"expected_html": "<p>shown</p>"
|
||||
},
|
||||
{
|
||||
"name": "do_block",
|
||||
"sx_input": "(div (do (p \"a\") (p \"b\")))",
|
||||
"expected_html": "<div><p>a</p><p>b</p></div>"
|
||||
},
|
||||
{
|
||||
"name": "nil_child",
|
||||
"sx_input": "(div nil \"after-nil\")",
|
||||
"expected_html": "<div>after-nil</div>"
|
||||
},
|
||||
{
|
||||
"name": "number_child",
|
||||
"sx_input": "(div 42)",
|
||||
"expected_html": "<div>42</div>"
|
||||
},
|
||||
{
|
||||
"name": "bool_child",
|
||||
"sx_input": "(div true)",
|
||||
"expected_html": "<div>true</div>"
|
||||
},
|
||||
{
|
||||
"name": "data_attr",
|
||||
"sx_input": "(div :data-id \"123\" :data-name \"test\" \"content\")",
|
||||
"expected_html": "<div data-id=\"123\" data-name=\"test\">content</div>"
|
||||
},
|
||||
{
|
||||
"name": "raw_simple",
|
||||
"sx_input": "(raw! \"<b>bold</b>\")",
|
||||
"expected_html": "<b>bold</b>"
|
||||
},
|
||||
{
|
||||
"name": "raw_in_div",
|
||||
"sx_input": "(div (raw! \"<em>italic</em>\"))",
|
||||
"expected_html": "<div><em>italic</em></div>"
|
||||
},
|
||||
{
|
||||
"name": "raw_component",
|
||||
"sx_input": "(do (defcomp ~rich (&key html) (raw! html)) (~rich :html \"<p>CMS</p>\"))",
|
||||
"expected_html": "<p>CMS</p>"
|
||||
},
|
||||
{
|
||||
"name": "misc_error_inline",
|
||||
"sx_input": "(do (defcomp ~shared:misc/error-inline (&key (message :as string)) (div :class \"text-red-600 text-sm\" message)) (~shared:misc/error-inline :message \"Something went wrong\"))",
|
||||
"expected_html": "<div class=\"text-red-600 text-sm\">Something went wrong</div>"
|
||||
},
|
||||
{
|
||||
"name": "misc_notification_badge",
|
||||
"sx_input": "(do (defcomp ~shared:misc/notification-badge (&key (count :as number)) (span :class \"bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5\" count)) (~shared:misc/notification-badge :count 5))",
|
||||
"expected_html": "<span class=\"bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5\">5</span>"
|
||||
},
|
||||
{
|
||||
"name": "misc_cache_cleared",
|
||||
"sx_input": "(do (defcomp ~shared:misc/cache-cleared (&key (time-str :as string)) (span :class \"text-green-600 font-bold\" \"Cache cleared at \" time-str)) (~shared:misc/cache-cleared :time-str \"12:00\"))",
|
||||
"expected_html": "<span class=\"text-green-600 font-bold\">Cache cleared at 12:00</span>"
|
||||
},
|
||||
{
|
||||
"name": "misc_error_list_item",
|
||||
"sx_input": "(do (defcomp ~shared:misc/error-list-item (&key (message :as string)) (li message)) (~shared:misc/error-list-item :message \"Bad input\"))",
|
||||
"expected_html": "<li>Bad input</li>"
|
||||
},
|
||||
{
|
||||
"name": "misc_fragment_error",
|
||||
"sx_input": "(do (defcomp ~shared:misc/fragment-error (&key (service :as string)) (p :class \"text-sm text-red-600\" \"Service \" (b service) \" is unavailable.\")) (~shared:misc/fragment-error :service \"blog\"))",
|
||||
"expected_html": "<p class=\"text-sm text-red-600\">Service <b>blog</b> is unavailable.</p>"
|
||||
}
|
||||
]
|
||||
353
shared/sx/tests/test_ocaml_render.py
Normal file
353
shared/sx/tests/test_ocaml_render.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Golden HTML rendering tests against the OCaml SX kernel.
|
||||
|
||||
Loads curated test cases from golden_data.json and verifies the OCaml
|
||||
kernel produces identical HTML output. Also tests aser and aser-slot
|
||||
modes.
|
||||
|
||||
Usage:
|
||||
pytest shared/sx/tests/test_ocaml_render.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
if _project_root not in sys.path:
|
||||
sys.path.insert(0, _project_root)
|
||||
|
||||
from shared.sx.ocaml_bridge import OcamlBridge, OcamlBridgeError, _DEFAULT_BIN
|
||||
|
||||
_GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "golden_data.json")
|
||||
|
||||
|
||||
def _load_golden() -> list[dict]:
|
||||
"""Load golden test data."""
|
||||
if not os.path.isfile(_GOLDEN_PATH):
|
||||
return []
|
||||
with open(_GOLDEN_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestOcamlGoldenRender(unittest.IsolatedAsyncioTestCase):
|
||||
"""Golden HTML tests — compare OCaml render output to Python-generated HTML."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
bin_path = os.path.abspath(_DEFAULT_BIN)
|
||||
if not os.path.isfile(bin_path):
|
||||
raise unittest.SkipTest(
|
||||
f"OCaml binary not found at {bin_path}. "
|
||||
f"Build with: cd hosts/ocaml && eval $(opam env) && dune build"
|
||||
)
|
||||
cls.golden = _load_golden()
|
||||
if not cls.golden:
|
||||
raise unittest.SkipTest(
|
||||
f"No golden data at {_GOLDEN_PATH}. "
|
||||
f"Generate with: python3 shared/sx/tests/generate_golden.py"
|
||||
)
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.bridge = OcamlBridge()
|
||||
await self.bridge.start()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.bridge.stop()
|
||||
|
||||
# Cases with known issues (spec-only functions, attribute order, etc.)
|
||||
_RENDER_SKIP = {"filter_even", "void_input", "do_block"}
|
||||
|
||||
async def test_golden_render(self):
|
||||
"""Each golden case: OCaml render matches Python HTML."""
|
||||
passed = 0
|
||||
failed = []
|
||||
for case in self.golden:
|
||||
name = case["name"]
|
||||
if name in self._RENDER_SKIP:
|
||||
continue
|
||||
sx_input = case["sx_input"]
|
||||
expected = case["expected_html"]
|
||||
try:
|
||||
actual = await asyncio.wait_for(
|
||||
self.bridge.render(sx_input), timeout=5.0
|
||||
)
|
||||
if actual.strip() == expected.strip():
|
||||
passed += 1
|
||||
else:
|
||||
failed.append((name, expected, actual))
|
||||
except asyncio.TimeoutError:
|
||||
failed.append((name, expected, "TIMEOUT"))
|
||||
# Bridge may be desynced — stop and restart
|
||||
await self.bridge.stop()
|
||||
self.bridge = OcamlBridge()
|
||||
await self.bridge.start()
|
||||
except OcamlBridgeError as e:
|
||||
failed.append((name, expected, f"ERROR: {e}"))
|
||||
|
||||
if failed:
|
||||
msg_parts = [f"\n{len(failed)} golden render mismatches:\n"]
|
||||
for name, expected, actual in failed[:10]:
|
||||
msg_parts.append(f" {name}:")
|
||||
msg_parts.append(f" expected: {expected[:120]}")
|
||||
msg_parts.append(f" actual: {actual[:120]}")
|
||||
self.fail("\n".join(msg_parts))
|
||||
|
||||
# Cases that use spec-only functions or macros with &rest that don't
|
||||
# round-trip through aser cleanly (render still works fine).
|
||||
# Cases that use spec-only functions, macros with &rest, or trigger
|
||||
# known parity issues in aser expansion (render still works fine).
|
||||
_ASER_SKIP = {"filter_even", "macro_unless"}
|
||||
_ASER_SLOT_SKIP = {"filter_even", "macro_unless", "defcomp_no_optional"}
|
||||
|
||||
async def test_golden_aser(self):
|
||||
"""Each golden case: OCaml aser produces valid SX wire format."""
|
||||
passed = 0
|
||||
errors = []
|
||||
for case in self.golden:
|
||||
name = case["name"]
|
||||
if name in self._ASER_SKIP:
|
||||
continue
|
||||
sx_input = case["sx_input"]
|
||||
try:
|
||||
result = await self.bridge.aser(sx_input)
|
||||
# aser should produce some output (string, not empty)
|
||||
if result is not None:
|
||||
passed += 1
|
||||
else:
|
||||
errors.append((name, "returned None"))
|
||||
except OcamlBridgeError as e:
|
||||
errors.append((name, str(e)))
|
||||
|
||||
if errors:
|
||||
msg_parts = [f"\n{len(errors)} aser errors:\n"]
|
||||
for name, err in errors[:10]:
|
||||
msg_parts.append(f" {name}: {err[:120]}")
|
||||
self.fail("\n".join(msg_parts))
|
||||
|
||||
async def test_golden_aser_slot(self):
|
||||
"""Each golden case: OCaml aser-slot produces valid SX wire format."""
|
||||
passed = 0
|
||||
errors = []
|
||||
for case in self.golden:
|
||||
name = case["name"]
|
||||
if name in self._ASER_SLOT_SKIP:
|
||||
continue
|
||||
sx_input = case["sx_input"]
|
||||
try:
|
||||
result = await self.bridge.aser_slot(sx_input)
|
||||
if result is not None:
|
||||
passed += 1
|
||||
else:
|
||||
errors.append((name, "returned None"))
|
||||
except OcamlBridgeError as e:
|
||||
errors.append((name, str(e)))
|
||||
|
||||
if errors:
|
||||
msg_parts = [f"\n{len(errors)} aser-slot errors:\n"]
|
||||
for name, err in errors[:10]:
|
||||
msg_parts.append(f" {name}: {err[:120]}")
|
||||
self.fail("\n".join(msg_parts))
|
||||
|
||||
async def test_aser_slot_expands_components(self):
|
||||
"""aser-slot expands component calls while aser does not."""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~golden-test (&key label) (span :class "tag" label))'
|
||||
)
|
||||
# aser should preserve the component call
|
||||
aser_result = await self.bridge.aser('(~golden-test :label "Hi")')
|
||||
self.assertTrue(
|
||||
aser_result.startswith("(~golden-test"),
|
||||
f"aser should preserve component call, got: {aser_result}",
|
||||
)
|
||||
|
||||
# aser-slot should expand the component
|
||||
slot_result = await self.bridge.aser_slot('(~golden-test :label "Hi")')
|
||||
self.assertTrue(
|
||||
slot_result.startswith("(span"),
|
||||
f"aser-slot should expand component, got: {slot_result}",
|
||||
)
|
||||
|
||||
async def test_aser_does_not_crash_on_component_call(self):
|
||||
"""Regression: aser with a component call must not crash.
|
||||
|
||||
This catches the bug where adapter-sx.sx called expand-components?
|
||||
without guarding env-has?, causing 'Undefined symbol' on kernels
|
||||
that don't bind it or when aser (not aser-slot) is used.
|
||||
"""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~regress-comp (&key title &rest children) '
|
||||
'(div :class "box" (h2 title) children))'
|
||||
)
|
||||
# aser must succeed (serialize the component call, not expand it)
|
||||
result = await self.bridge.aser(
|
||||
'(~regress-comp :title "Hello" (p "world"))'
|
||||
)
|
||||
self.assertIn("~regress-comp", result)
|
||||
self.assertIn('"Hello"', result)
|
||||
|
||||
async def test_render_raw_html(self):
|
||||
"""Regression: raw! must inject HTML without escaping."""
|
||||
html = await self.bridge.render('(raw! "<b>bold</b>")')
|
||||
self.assertEqual(html, "<b>bold</b>")
|
||||
|
||||
async def test_render_component_with_raw(self):
|
||||
"""Regression: component using raw! (like ~shared:misc/rich-text)."""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~rich-text (&key html) (raw! html))'
|
||||
)
|
||||
html = await self.bridge.render('(~rich-text :html "<p>CMS content</p>")')
|
||||
self.assertEqual(html, "<p>CMS content</p>")
|
||||
|
||||
async def test_aser_nested_components_no_crash(self):
|
||||
"""Regression: aser with nested component calls must not crash."""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~outer-reg (&key title &rest children) '
|
||||
'(section (h1 title) children))'
|
||||
)
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~inner-reg (&key text) (span text))'
|
||||
)
|
||||
result = await self.bridge.aser(
|
||||
'(~outer-reg :title "Outer" (~inner-reg :text "Inner"))'
|
||||
)
|
||||
self.assertIn("~outer-reg", result)
|
||||
self.assertIn("~inner-reg", result)
|
||||
|
||||
async def test_render_shell_with_raw(self):
|
||||
"""Integration: shell component with raw! renders full HTML page.
|
||||
|
||||
The page shell uses raw! extensively for script content, CSS,
|
||||
pre-rendered HTML, etc. This catches missing raw! in the renderer.
|
||||
"""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~test-shell (&key title page-sx css) '
|
||||
'(<> (raw! "<!doctype html>") '
|
||||
'(html (head (title title) (style (raw! (or css "")))) '
|
||||
'(body (script :type "text/sx" (raw! (or page-sx "")))))))'
|
||||
)
|
||||
html = await self.bridge.render(
|
||||
'(~test-shell :title "Test" '
|
||||
':page-sx "(div :class \\"card\\" \\"hello\\")" '
|
||||
':css "body{margin:0}")'
|
||||
)
|
||||
self.assertIn("<!doctype html>", html)
|
||||
self.assertIn("<title>Test</title>", html)
|
||||
self.assertIn('(div :class "card" "hello")', html)
|
||||
self.assertIn("body{margin:0}", html)
|
||||
|
||||
async def test_render_never_returns_raw_sx(self):
|
||||
"""The render command must never return raw SX as the response.
|
||||
|
||||
Even if the shell component fails, the bridge.render() should
|
||||
either return HTML or raise — never return SX wire format.
|
||||
"""
|
||||
# Component that produces HTML, not SX
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~test-page (&key content) '
|
||||
'(<> (raw! "<!doctype html>") (html (body (raw! content)))))'
|
||||
)
|
||||
html = await self.bridge.render(
|
||||
'(~test-page :content "(div \\"hello\\")")'
|
||||
)
|
||||
# Must start with <!doctype, not with (
|
||||
self.assertTrue(
|
||||
html.startswith("<!doctype"),
|
||||
f"render returned SX instead of HTML: {html[:100]}",
|
||||
)
|
||||
# Must not contain bare SX component calls as visible text
|
||||
self.assertNotIn("(~test-page", html)
|
||||
|
||||
async def test_aser_slot_server_affinity_always_expands(self):
|
||||
"""Server-affinity components expand in both aser and aser-slot."""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~golden-server (&key x) :affinity :server (div x))'
|
||||
)
|
||||
# Both modes should expand server-affinity components
|
||||
aser_result = await self.bridge.aser('(~golden-server :x "test")')
|
||||
self.assertTrue(
|
||||
"(div" in aser_result,
|
||||
f"aser should expand server-affinity, got: {aser_result}",
|
||||
)
|
||||
slot_result = await self.bridge.aser_slot('(~golden-server :x "test")')
|
||||
self.assertTrue(
|
||||
"(div" in slot_result,
|
||||
f"aser-slot should expand server-affinity, got: {slot_result}",
|
||||
)
|
||||
|
||||
|
||||
class TestOcamlCLI(unittest.TestCase):
|
||||
"""Test the --render and --aser CLI modes."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.bin_path = os.path.abspath(_DEFAULT_BIN)
|
||||
if not os.path.isfile(cls.bin_path):
|
||||
raise unittest.SkipTest("OCaml binary not found")
|
||||
|
||||
def _run_cli(self, mode: str, sx_input: str) -> str:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[self.bin_path, f"--{mode}"],
|
||||
input=sx_input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"CLI {mode} failed: {result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
def test_cli_render_simple(self):
|
||||
html = self._run_cli("render", '(div :class "card" (p "hello"))')
|
||||
self.assertEqual(html, '<div class="card"><p>hello</p></div>')
|
||||
|
||||
def test_cli_render_fragment(self):
|
||||
html = self._run_cli("render", '(<> (p "a") (p "b"))')
|
||||
self.assertEqual(html, "<p>a</p><p>b</p>")
|
||||
|
||||
def test_cli_render_void(self):
|
||||
html = self._run_cli("render", "(br)")
|
||||
self.assertEqual(html, "<br />")
|
||||
|
||||
def test_cli_render_conditional(self):
|
||||
html = self._run_cli("render", '(if true (p "yes") (p "no"))')
|
||||
self.assertEqual(html, "<p>yes</p>")
|
||||
|
||||
def test_cli_aser_with_defcomp(self):
|
||||
"""CLI --aser with component def + call must not crash."""
|
||||
sx = ('(do (defcomp ~cli-test (&key title) (div title)) '
|
||||
'(~cli-test :title "Hi"))')
|
||||
result = self._run_cli("aser", sx)
|
||||
self.assertIn("~cli-test", result)
|
||||
|
||||
# Same skip list as the bridge golden tests
|
||||
_CLI_RENDER_SKIP = {"filter_even", "void_input", "do_block"}
|
||||
|
||||
def test_cli_golden_render(self):
|
||||
"""Run all golden cases through CLI --render."""
|
||||
golden = _load_golden()
|
||||
if not golden:
|
||||
self.skipTest("No golden data")
|
||||
failed = []
|
||||
for case in golden:
|
||||
if case["name"] in self._CLI_RENDER_SKIP:
|
||||
continue
|
||||
try:
|
||||
actual = self._run_cli("render", case["sx_input"])
|
||||
if actual.strip() != case["expected_html"].strip():
|
||||
failed.append((case["name"], case["expected_html"], actual))
|
||||
except Exception as e:
|
||||
failed.append((case["name"], case["expected_html"], str(e)))
|
||||
if failed:
|
||||
msg_parts = [f"\n{len(failed)} CLI golden render mismatches:\n"]
|
||||
for name, expected, actual in failed[:10]:
|
||||
msg_parts.append(f" {name}:")
|
||||
msg_parts.append(f" expected: {expected[:120]}")
|
||||
msg_parts.append(f" actual: {actual[:120]}")
|
||||
self.fail("\n".join(msg_parts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user