"""Tests for OCaml kernel ↔ Python page helper IO bridge. Verifies that: 1. Helper injection registers functions in the OCaml kernel 2. The kernel can call helpers via (io-request "helper" ...) 3. aser_slot expands components that use helpers 4. Caching eliminates redundant IO round-trips Usage: pytest shared/sx/tests/test_ocaml_helpers.py -v """ import asyncio import os import sys import time 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, _escape def _skip_if_no_binary(): 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" ) class TestHelperInjection(unittest.IsolatedAsyncioTestCase): """Test that page helpers can be injected into the OCaml kernel.""" @classmethod def setUpClass(cls): _skip_if_no_binary() async def asyncSetUp(self): self.bridge = OcamlBridge() await self.bridge.start() # Load spec + adapter (needed for aser) spec_dir = os.path.join(_project_root, "spec") web_dir = os.path.join(_project_root, "web") for f in ["parser.sx", "render.sx"]: path = os.path.join(spec_dir, f) if os.path.isfile(path): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(path)}")') await self.bridge._read_until_ok(ctx=None) adapter = os.path.join(web_dir, "adapter-sx.sx") if os.path.isfile(adapter): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(adapter)}")') await self.bridge._read_until_ok(ctx=None) async def asyncTearDown(self): await self.bridge.stop() async def _inject_test_helper(self, name: str, nargs: int): """Inject a single helper proxy into the kernel.""" param_names = " ".join(chr(97 + i) for i in range(nargs)) arg_list = " ".join(chr(97 + i) for i in range(nargs)) sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(sx_def)}")') await self.bridge._read_until_ok(ctx=None) async def test_helper_call_returns_value(self): """Injected helper can be called and returns IO result.""" # The "helper" native binding is already in the kernel. # Define a test helper that calls (helper "json-encode" value) await self._inject_test_helper("json-encode", 1) # Call it via eval — should yield io-request, Python dispatches result = await self.bridge.eval( '(json-encode "hello")', ctx={"_helper_service": "sx"} ) self.assertIn("hello", result) async def test_helper_with_two_args(self): """Helper with 2 args works (e.g. highlight pattern).""" # Define a 2-arg test helper via the generic helper binding sx_def = '(define test-two-args (fn (a b) (helper "json-encode" (str a ":" b))))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(sx_def)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.eval( '(test-two-args "hello" "world")', ctx={"_helper_service": "sx"} ) self.assertIn("hello:world", result) async def test_undefined_helper_errors(self): """Calling an uninjected helper raises an error.""" with self.assertRaises(OcamlBridgeError) as cm: await self.bridge.eval('(nonexistent-helper "arg")') self.assertIn("Undefined symbol", str(cm.exception)) async def test_helper_in_aser(self): """Helper works inside aser — result inlined in SX output.""" await self._inject_test_helper("json-encode", 1) # aser a component-like expression that calls the helper result = await self.bridge.aser( '(div :class "test" (json-encode "hello"))', ctx={"_helper_service": "sx"} ) # The aser should evaluate json-encode and inline the result self.assertIn("div", result) self.assertIn("hello", result) async def test_helper_in_aser_slot_component(self): """aser_slot expands component containing helper call.""" await self._inject_test_helper("json-encode", 1) # Define a component that calls the helper async with self.bridge._lock: await self.bridge._send( '(load-source "(defcomp ~test/code-display (&key code) ' '(pre (code code)))")' ) await self.bridge._read_until_ok(ctx=None) # aser_slot should expand the component, evaluating the body result = await self.bridge.aser_slot( '(~test/code-display :code (json-encode "test-value"))', ctx={"_helper_service": "sx"} ) # Should contain expanded HTML tags, not component call self.assertIn("pre", result) self.assertIn("test-value", result) # Should NOT contain the component call self.assertNotIn("~test/code-display", result) class TestBatchIO(unittest.IsolatedAsyncioTestCase): """Test that batchable helper calls are collected and resolved concurrently.""" @classmethod def setUpClass(cls): _skip_if_no_binary() async def asyncSetUp(self): self.bridge = OcamlBridge() await self.bridge.start() spec_dir = os.path.join(_project_root, "spec") web_dir = os.path.join(_project_root, "web") for f in ["parser.sx", "render.sx"]: path = os.path.join(spec_dir, f) if os.path.isfile(path): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(path)}")') await self.bridge._read_until_ok(ctx=None) adapter = os.path.join(web_dir, "adapter-sx.sx") if os.path.isfile(adapter): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(adapter)}")') await self.bridge._read_until_ok(ctx=None) async def asyncTearDown(self): await self.bridge.stop() async def test_batch_highlight_calls(self): """Multiple highlight calls in aser_slot are batched, not sequential.""" # Map highlight to json-encode (available without Quart app) sx = '(define highlight (fn (a b) (helper "json-encode" a)))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(sx)}")') await self.bridge._read_until_ok(ctx=None) comp = ( '(defcomp ~test/batch (&key)' ' (div (p (highlight "aaa" "x"))' ' (p (highlight "bbb" "x"))' ' (p (highlight "ccc" "x"))))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser_slot( '(~test/batch)', ctx={"_helper_service": "sx"}) # All 3 values present — placeholders replaced self.assertIn("aaa", result) self.assertIn("bbb", result) self.assertIn("ccc", result) # No placeholder markers remaining self.assertNotIn("\u00ab", result) # « self.assertNotIn("\u00bb", result) # » async def test_batch_faster_than_sequential(self): """Batched IO should be faster than N sequential round-trips.""" sx = '(define highlight (fn (a b) (helper "json-encode" a)))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(sx)}")') await self.bridge._read_until_ok(ctx=None) calls = " ".join(f'(p (highlight "v{i}" "x"))' for i in range(10)) comp = f'(defcomp ~test/perf (&key) (div {calls}))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp)}")') await self.bridge._read_until_ok(ctx=None) t0 = time.monotonic() result = await self.bridge.aser_slot( '(~test/perf)', ctx={"_helper_service": "sx"}) elapsed = time.monotonic() - t0 # All 10 values present for i in range(10): self.assertIn(f"v{i}", result) # Should complete in under 2 seconds (batched, not 10 × round-trip) self.assertLess(elapsed, 2.0, f"10 batched IO calls took {elapsed:.1f}s (target: <2s)") class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase): """Test that helper IO round-trips are fast enough for production.""" @classmethod def setUpClass(cls): _skip_if_no_binary() async def asyncSetUp(self): self.bridge = OcamlBridge() await self.bridge.start() spec_dir = os.path.join(_project_root, "spec") web_dir = os.path.join(_project_root, "web") for f in ["parser.sx", "render.sx"]: path = os.path.join(spec_dir, f) if os.path.isfile(path): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(path)}")') await self.bridge._read_until_ok(ctx=None) adapter = os.path.join(web_dir, "adapter-sx.sx") if os.path.isfile(adapter): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(adapter)}")') await self.bridge._read_until_ok(ctx=None) async def asyncTearDown(self): await self.bridge.stop() async def test_sequential_helper_calls_timing(self): """Measure round-trip time for sequential helper calls.""" # Inject json-encode as a fast helper param_names = "a" sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(sx_def)}")') await self.bridge._read_until_ok(ctx=None) # Time 20 sequential calls (simulating a page with 20 highlight calls) n_calls = 20 calls = " ".join(f'(json-encode "{i}")' for i in range(n_calls)) expr = f'(list {calls})' start = time.monotonic() result = await self.bridge.eval(expr, ctx={"_helper_service": "sx"}) elapsed = time.monotonic() - start # Should complete in under 5 seconds (generous for 20 IO round-trips) self.assertLess(elapsed, 5.0, f"20 helper IO round-trips took {elapsed:.1f}s (target: <5s)") async def test_aser_slot_with_many_helper_calls(self): """aser_slot with multiple helper calls completes in reasonable time.""" sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))' async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(sx_def)}")') await self.bridge._read_until_ok(ctx=None) # Define a component with multiple helper calls comp_def = ( '(defcomp ~test/multi-helper (&key)' ' (div' ' (p (json-encode "a"))' ' (p (json-encode "b"))' ' (p (json-encode "c"))' ' (p (json-encode "d"))' ' (p (json-encode "e"))))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp_def)}")') await self.bridge._read_until_ok(ctx=None) start = time.monotonic() result = await self.bridge.aser_slot( '(~test/multi-helper)', ctx={"_helper_service": "sx"} ) elapsed = time.monotonic() - start self.assertIn("div", result) self.assertLess(elapsed, 3.0, f"aser_slot with 5 helpers took {elapsed:.1f}s (target: <3s)") class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase): """Test that :affinity :client components are NOT expanded by aser_slot.""" @classmethod def setUpClass(cls): _skip_if_no_binary() async def asyncSetUp(self): self.bridge = OcamlBridge() await self.bridge.start() spec_dir = os.path.join(_project_root, "spec") web_dir = os.path.join(_project_root, "web") for f in ["parser.sx", "render.sx"]: path = os.path.join(spec_dir, f) if os.path.isfile(path): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(path)}")') await self.bridge._read_until_ok(ctx=None) adapter = os.path.join(web_dir, "adapter-sx.sx") if os.path.isfile(adapter): async with self.bridge._lock: await self.bridge._send(f'(load "{_escape(adapter)}")') await self.bridge._read_until_ok(ctx=None) async def asyncTearDown(self): await self.bridge.stop() async def test_client_affinity_not_expanded(self): """Components with :affinity :client stay as calls in aser_slot.""" comp_def = ( '(defcomp ~test/client-only () :affinity :client' ' (div "browser-only-content"))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp_def)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser_slot('(~test/client-only)') # Should remain as a component call, NOT expanded self.assertIn("~test/client-only", result) self.assertNotIn("browser-only-content", result) async def test_server_affinity_expanded(self): """Components with :affinity :server are expanded by regular aser.""" comp_def = ( '(defcomp ~test/server-only (&key label) :affinity :server' ' (div :class "server" label))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp_def)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser( '(~test/server-only :label "hello")') # Should be expanded — div with class, not component call self.assertIn("div", result) self.assertIn("server", result) self.assertNotIn("~test/server-only", result) async def test_auto_affinity_not_expanded_by_aser(self): """Default affinity components are NOT expanded by regular aser.""" comp_def = ( '(defcomp ~test/auto-comp (&key label)' ' (div "auto-content" label))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp_def)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser( '(~test/auto-comp :label "hi")') # Should remain as component call self.assertIn("~test/auto-comp", result) async def test_island_not_expanded_by_aser_slot(self): """Islands are NEVER expanded server-side, even with expand-all.""" island_def = ( '(defisland ~test/reactive-isle (&key label)' ' (div (deref (signal 0)) label))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(island_def)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser_slot( '(~test/reactive-isle :label "hello")') # Island should be serialized as a call, NOT expanded self.assertIn("~test/reactive-isle", result) # Body content (deref, signal) should NOT appear self.assertNotIn("deref", result) self.assertNotIn("signal", result) async def test_island_preserved_inside_expanded_component(self): """Island calls survive inside aser_slot-expanded components.""" src = ( '(defisland ~test/inner-isle (&key v) (span (deref (signal v))))' '(defcomp ~test/outer-comp (&key title)' ' (div (h1 title) (~test/inner-isle :v 42)))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(src)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser_slot( '(~test/outer-comp :title "Test")') # Outer component expanded self.assertNotIn("~test/outer-comp", result) self.assertIn("div", result) self.assertIn("Test", result) # Inner island preserved as call self.assertIn("~test/inner-isle", result) async def test_auto_affinity_expanded_by_aser_slot(self): """Default affinity components ARE expanded by aser_slot.""" comp_def = ( '(defcomp ~test/auto-comp2 (&key label)' ' (div "expanded" label))' ) async with self.bridge._lock: await self.bridge._send(f'(load-source "{_escape(comp_def)}")') await self.bridge._read_until_ok(ctx=None) result = await self.bridge.aser_slot( '(~test/auto-comp2 :label "hi")') # Should be expanded self.assertIn("div", result) self.assertIn("expanded", result) self.assertNotIn("~test/auto-comp2", result) if __name__ == "__main__": unittest.main()