221 lines
8.2 KiB
Python
221 lines
8.2 KiB
Python
"""Tests for the OCaml SX bridge."""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
# Add project root to path
|
|
_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, _escape, _parse_response, _serialize_for_ocaml
|
|
|
|
|
|
class TestHelpers(unittest.TestCase):
|
|
"""Test helper functions (no subprocess needed)."""
|
|
|
|
def test_escape_basic(self):
|
|
self.assertEqual(_escape('hello'), 'hello')
|
|
self.assertEqual(_escape('say "hi"'), 'say \\"hi\\"')
|
|
self.assertEqual(_escape('a\\b'), 'a\\\\b')
|
|
self.assertEqual(_escape('line\nbreak'), 'line\\nbreak')
|
|
|
|
def test_parse_response_ok_empty(self):
|
|
self.assertEqual(_parse_response("(ok)"), ("ok", None))
|
|
|
|
def test_parse_response_ok_number(self):
|
|
self.assertEqual(_parse_response("(ok 3)"), ("ok", "3"))
|
|
|
|
def test_parse_response_ok_string(self):
|
|
kind, val = _parse_response('(ok "<div>hi</div>")')
|
|
self.assertEqual(kind, "ok")
|
|
self.assertEqual(val, "<div>hi</div>")
|
|
|
|
def test_parse_response_error(self):
|
|
kind, val = _parse_response('(error "something broke")')
|
|
self.assertEqual(kind, "error")
|
|
self.assertEqual(val, "something broke")
|
|
|
|
def test_serialize_none(self):
|
|
self.assertEqual(_serialize_for_ocaml(None), "nil")
|
|
|
|
def test_serialize_bool(self):
|
|
self.assertEqual(_serialize_for_ocaml(True), "true")
|
|
self.assertEqual(_serialize_for_ocaml(False), "false")
|
|
|
|
def test_serialize_number(self):
|
|
self.assertEqual(_serialize_for_ocaml(42), "42")
|
|
self.assertEqual(_serialize_for_ocaml(3.14), "3.14")
|
|
|
|
def test_serialize_string(self):
|
|
self.assertEqual(_serialize_for_ocaml("hello"), '"hello"')
|
|
self.assertEqual(_serialize_for_ocaml('say "hi"'), '"say \\"hi\\""')
|
|
|
|
def test_serialize_list(self):
|
|
self.assertEqual(_serialize_for_ocaml([1, 2, 3]), "(list 1 2 3)")
|
|
|
|
def test_serialize_dict(self):
|
|
result = _serialize_for_ocaml({"a": 1})
|
|
self.assertEqual(result, "{:a 1}")
|
|
|
|
|
|
class TestBridge(unittest.IsolatedAsyncioTestCase):
|
|
"""Integration tests — require the OCaml binary to be built."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# Check if binary exists
|
|
from shared.sx.ocaml_bridge import _DEFAULT_BIN
|
|
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"
|
|
)
|
|
|
|
async def asyncSetUp(self):
|
|
self.bridge = OcamlBridge()
|
|
await self.bridge.start()
|
|
|
|
async def asyncTearDown(self):
|
|
await self.bridge.stop()
|
|
|
|
async def test_ping(self):
|
|
self.assertTrue(await self.bridge.ping())
|
|
|
|
async def test_eval_arithmetic(self):
|
|
result = await self.bridge.eval("(+ 1 2)")
|
|
self.assertEqual(result, "3")
|
|
|
|
async def test_eval_string(self):
|
|
result = await self.bridge.eval('(str "hello" " " "world")')
|
|
self.assertIn("hello world", result)
|
|
|
|
async def test_render_simple(self):
|
|
html = await self.bridge.render('(div (p "hello"))')
|
|
self.assertEqual(html, "<div><p>hello</p></div>")
|
|
|
|
async def test_render_attrs(self):
|
|
html = await self.bridge.render('(div :class "card" (p "hi"))')
|
|
self.assertIn('class="card"', html)
|
|
self.assertIn("<p>hi</p>", html)
|
|
|
|
async def test_render_void_element(self):
|
|
html = await self.bridge.render("(br)")
|
|
self.assertEqual(html, "<br />")
|
|
|
|
async def test_load_source_defcomp(self):
|
|
count = await self.bridge.load_source(
|
|
'(defcomp ~test-card (&key title) (div :class "card" (h2 title)))'
|
|
)
|
|
self.assertEqual(count, 1)
|
|
html = await self.bridge.render('(~test-card :title "Hello")')
|
|
self.assertIn("Hello", html)
|
|
self.assertIn("card", html)
|
|
|
|
async def test_reset(self):
|
|
await self.bridge.load_source("(define x 42)")
|
|
result = await self.bridge.eval("x")
|
|
self.assertEqual(result, "42")
|
|
await self.bridge.reset()
|
|
with self.assertRaises(OcamlBridgeError):
|
|
await self.bridge.eval("x")
|
|
|
|
async def test_render_conditional(self):
|
|
html = await self.bridge.render('(if true (p "yes") (p "no"))')
|
|
self.assertEqual(html, "<p>yes</p>")
|
|
|
|
async def test_render_let(self):
|
|
html = await self.bridge.render('(let (x "hi") (p x))')
|
|
self.assertEqual(html, "<p>hi</p>")
|
|
|
|
async def test_render_map(self):
|
|
html = await self.bridge.render(
|
|
"(map (lambda (x) (li x)) (list \"a\" \"b\" \"c\"))"
|
|
)
|
|
self.assertEqual(html, "<li>a</li><li>b</li><li>c</li>")
|
|
|
|
async def test_render_fragment(self):
|
|
html = await self.bridge.render('(<> (p "a") (p "b"))')
|
|
self.assertEqual(html, "<p>a</p><p>b</p>")
|
|
|
|
async def test_eval_error(self):
|
|
with self.assertRaises(OcamlBridgeError):
|
|
await self.bridge.eval("(undefined-symbol-xyz)")
|
|
|
|
async def test_render_component_with_children(self):
|
|
await self.bridge.load_source(
|
|
'(defcomp ~wrapper (&rest children) (div :class "wrap" children))'
|
|
)
|
|
html = await self.bridge.render('(~wrapper (p "inside"))')
|
|
self.assertIn("wrap", html)
|
|
self.assertIn("<p>inside</p>", html)
|
|
|
|
async def test_render_macro(self):
|
|
await self.bridge.load_source(
|
|
"(defmacro unless (cond &rest body) (list 'if (list 'not cond) (cons 'do body)))"
|
|
)
|
|
html = await self.bridge.render('(unless false (p "shown"))')
|
|
self.assertEqual(html, "<p>shown</p>")
|
|
|
|
# ------------------------------------------------------------------
|
|
# ListRef regression tests — the `list` primitive returns ListRef
|
|
# (mutable), not List (immutable). Macro expansions that construct
|
|
# AST via `list` produce ListRef nodes. The renderer must handle
|
|
# both List and ListRef at every structural match point.
|
|
# ------------------------------------------------------------------
|
|
|
|
async def test_render_macro_generates_cond(self):
|
|
"""Macro that programmatically builds a (cond ...) with list."""
|
|
await self.bridge.load_source(
|
|
"(defmacro pick (x) "
|
|
" (list 'cond "
|
|
" (list (list '= x 1) '(p \"one\")) "
|
|
" (list (list '= x 2) '(p \"two\")) "
|
|
" (list ':else '(p \"other\"))))"
|
|
)
|
|
html = await self.bridge.render("(pick 2)")
|
|
self.assertEqual(html, "<p>two</p>")
|
|
|
|
async def test_render_macro_generates_let(self):
|
|
"""Macro that programmatically builds a (let ...) with list."""
|
|
await self.bridge.load_source(
|
|
"(defmacro with-greeting (name &rest body) "
|
|
" (list 'let (list (list 'greeting (list 'str \"Hello \" name))) "
|
|
" (cons 'do body)))"
|
|
)
|
|
html = await self.bridge.render('(with-greeting "World" (p greeting))')
|
|
self.assertEqual(html, "<p>Hello World</p>")
|
|
|
|
async def test_render_macro_nested_html_tags(self):
|
|
"""Macro expansion containing nested HTML tags via list."""
|
|
await self.bridge.load_source(
|
|
"(defmacro card (title &rest body) "
|
|
" (list 'div ':class \"card\" "
|
|
" (list 'h2 title) "
|
|
" (cons 'do body)))"
|
|
)
|
|
html = await self.bridge.render('(card "Title" (p "content"))')
|
|
self.assertIn('<div class="card">', html)
|
|
self.assertIn("<h2>Title</h2>", html)
|
|
self.assertIn("<p>content</p>", html)
|
|
|
|
async def test_render_eval_returns_listref(self):
|
|
"""Values created at runtime via (list ...) are ListRef."""
|
|
await self.bridge.load_source(
|
|
"(define make-items (lambda () (list "
|
|
' (list "a") (list "b") (list "c"))))'
|
|
)
|
|
html = await self.bridge.render(
|
|
"(ul (map (lambda (x) (li (first x))) (make-items)))"
|
|
)
|
|
self.assertIn("<li>a</li>", html)
|
|
self.assertIn("<li>b</li>", html)
|
|
self.assertIn("<li>c</li>", html)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|