Files
rose-ash/shared/sx/tests/test_ocaml_helpers.py
giles 96e7bbbac1 Non-blocking batch IO for OCaml kernel + stable component hash
OCaml kernel (sx_server.ml):
- Batch IO mode for aser-slot: batchable helpers (highlight,
  component-source) return placeholders during evaluation instead
  of blocking on stdin. After aser completes, all batched requests
  are flushed to Python at once.
- Python processes them concurrently with asyncio.gather.
- Placeholders (using «IO:N» markers) are replaced with actual
  values in the result string.
- Non-batchable IO (query, action, ctx, request-arg) still uses
  blocking mode — their results drive control flow.

Python bridge (ocaml_bridge.py):
- _read_until_ok handles batched protocol: collects io-request
  lines with numeric IDs, processes on (io-done N) with gather.
- IO result cache for pure helpers — eliminates redundant calls.
- _handle_io_request strips batch ID from request format.

Component caching (jinja_bridge.py):
- Hash computed from FULL component env (all names + bodies),
  not per-page subset. Stable across all pages — browser caches
  once, no re-download on navigation between pages.
- invalidate_component_hash() called on hot-reload.

Tests: 15/15 OCaml helper tests pass (2 new batch IO tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:53:01 +00:00

437 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()