Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Evaluator: data-first higher-order forms — ho-swap-args auto-detects (map coll fn) vs (map fn coll), both work. Threading + HO: (-> data (map fn)) dispatches through CEK HO machinery via quoted-value splice. 17 new tests in test-cek-advanced.sx. Fix plan pages: add mother-language, isolated-evaluator, rust-wasm-host to page-functions.sx plan() — were in defpage but missing from URL router. Aser error handling: pages.py now catches EvalError separately, renders visible error banner instead of silently sending empty content. All except blocks include traceback in logs. Scope primitives: register collect!/collected/clear-collected!/emitted/ emit!/context in shared/sx/primitives.py so hand-written _aser can resolve them (fixes ~cssx/flush expansion failure). New test file: shared/sx/tests/test_aser_errors.py — 19 pytest tests for error propagation through all aser control flow forms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
"""Tests for aser (SX wire format) error propagation.
|
|
|
|
Verifies that evaluation errors inside control flow forms (case, cond, if,
|
|
when, let, begin) propagate correctly — they must throw, not silently
|
|
produce wrong output or fall through to :else branches.
|
|
|
|
This test file targets the production bug where a case body referencing an
|
|
undefined symbol was silently swallowed, causing the case to appear to fall
|
|
through to :else instead of raising an error.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from shared.sx.ref.sx_ref import (
|
|
aser,
|
|
sx_parse as parse_all,
|
|
make_env,
|
|
eval_expr,
|
|
trampoline,
|
|
serialize as sx_serialize,
|
|
)
|
|
from shared.sx.types import NIL, EvalError
|
|
|
|
|
|
def _render_sx(source: str, env=None) -> str:
|
|
"""Parse SX source and serialize via aser (sync)."""
|
|
if env is None:
|
|
env = make_env()
|
|
exprs = parse_all(source)
|
|
result = ""
|
|
for expr in exprs:
|
|
val = aser(expr, env)
|
|
if isinstance(val, str):
|
|
result += val
|
|
elif val is None or val is NIL:
|
|
pass
|
|
else:
|
|
result += sx_serialize(val)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Case — matched branch errors must throw, not fall through
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCaseErrorPropagation:
|
|
def test_matched_branch_undefined_symbol_throws(self):
|
|
"""If the matched case body references an undefined symbol, the aser
|
|
must throw — NOT silently skip to :else."""
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(case "x" "x" undefined_sym :else "fallback")')
|
|
|
|
def test_else_branch_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(case "miss" "x" "ok" :else undefined_sym)')
|
|
|
|
def test_matched_branch_nested_error_throws(self):
|
|
"""Error inside a tag within the matched body must propagate."""
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(case "a" "a" (div (p undefined_nested)) :else (p "index"))')
|
|
|
|
def test_unmatched_correctly_falls_through(self):
|
|
"""Verify :else works when no clause matches (happy path)."""
|
|
result = _render_sx('(case "miss" "x" "found" :else "fallback")')
|
|
assert "fallback" in result
|
|
|
|
def test_matched_branch_succeeds(self):
|
|
"""Verify the happy path: matched branch evaluates normally."""
|
|
result = _render_sx('(case "ok" "ok" (p "matched") :else "fallback")')
|
|
assert "matched" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cond — matched branch errors must throw
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCondErrorPropagation:
|
|
def test_matched_branch_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(cond true undefined_cond_sym :else "fallback")')
|
|
|
|
def test_else_branch_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(cond false "skip" :else undefined_cond_sym)')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# If / When — body errors must throw
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIfWhenErrorPropagation:
|
|
def test_if_true_branch_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(if true undefined_if_sym "fallback")')
|
|
|
|
def test_when_body_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(when true undefined_when_sym)')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Let — binding or body errors must throw
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLetErrorPropagation:
|
|
def test_binding_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(let ((x undefined_let_sym)) (p x))')
|
|
|
|
def test_body_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(let ((x 1)) (p undefined_let_body_sym))')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Begin/Do — body errors must throw
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBeginErrorPropagation:
|
|
def test_do_body_error_throws(self):
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
_render_sx('(do "ok" undefined_do_sym)')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync aser: components serialize WITHOUT expansion (by design)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSyncAserComponentSerialization:
|
|
"""The sync aser serializes component calls as SX wire format without
|
|
expanding the body. This is correct — expansion only happens in the
|
|
async path with expand_components=True."""
|
|
|
|
def test_component_in_case_serializes_without_expanding(self):
|
|
"""Sync aser should serialize the component call, not expand it."""
|
|
result = _render_sx(
|
|
'(do (defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
|
|
' (case "slug" "slug" (~broken :title "test") '
|
|
' :else "index"))'
|
|
)
|
|
# Component call is serialized as SX, not expanded — no error
|
|
assert "~broken" in result
|
|
|
|
def test_working_component_in_case_serializes(self):
|
|
result = _render_sx(
|
|
'(do (defcomp ~working (&key title) (div (p title)))'
|
|
' (case "ok" "ok" (~working :title "hello") '
|
|
' :else "index"))'
|
|
)
|
|
assert "~working" in result
|
|
|
|
def test_unmatched_case_falls_through_correctly(self):
|
|
result = _render_sx(
|
|
'(do (defcomp ~page (&key x) (div x))'
|
|
' (case "miss" "hit" (~page :x "found") '
|
|
' :else "index"))'
|
|
)
|
|
assert "index" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async aser with expand_components=True — the production path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAsyncAserComponentExpansion:
|
|
"""Tests the production code path: async aser with component expansion
|
|
enabled. Errors in expanded component bodies must propagate, not be
|
|
silently swallowed."""
|
|
|
|
def _async_render(self, source: str) -> str:
|
|
"""Render via the async aser with component expansion enabled."""
|
|
import asyncio
|
|
from shared.sx.ref.sx_ref import async_aser, _expand_components_cv
|
|
exprs = parse_all(source)
|
|
env = make_env()
|
|
|
|
async def run():
|
|
token = _expand_components_cv.set(True)
|
|
try:
|
|
result = ""
|
|
for expr in exprs:
|
|
val = await async_aser(expr, env, None)
|
|
if isinstance(val, str):
|
|
result += val
|
|
elif val is None or val is NIL:
|
|
pass
|
|
else:
|
|
result += sx_serialize(val)
|
|
return result
|
|
finally:
|
|
_expand_components_cv.reset(token)
|
|
|
|
return asyncio.run(run())
|
|
|
|
def test_expanded_component_with_undefined_symbol_throws(self):
|
|
"""When expand_components is True and the component body references
|
|
an undefined symbol, the error must propagate — not be swallowed."""
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
self._async_render(
|
|
'(do (defcomp ~broken (&key title) '
|
|
' (div (p title) (p no_such_helper)))'
|
|
' (case "slug" "slug" (~broken :title "test") '
|
|
' :else "index"))'
|
|
)
|
|
|
|
def test_expanded_working_component_succeeds(self):
|
|
result = self._async_render(
|
|
'(do (defcomp ~working (&key title) (div (p title)))'
|
|
' (case "ok" "ok" (~working :title "hello") '
|
|
' :else "index"))'
|
|
)
|
|
assert "hello" in result
|
|
|
|
def test_expanded_unmatched_falls_through(self):
|
|
result = self._async_render(
|
|
'(do (defcomp ~page (&key x) (div x))'
|
|
' (case "miss" "hit" (~page :x "found") '
|
|
' :else "index"))'
|
|
)
|
|
assert "index" in result
|
|
|
|
def test_hand_written_aser_also_propagates(self):
|
|
"""Test the hand-written _aser in async_eval.py (the production
|
|
path used by page rendering)."""
|
|
import asyncio
|
|
from shared.sx.async_eval import (
|
|
async_eval_slot_to_sx, RequestContext,
|
|
)
|
|
from shared.sx.ref.sx_ref import aser
|
|
|
|
env = make_env()
|
|
# Define the component via sync aser
|
|
for expr in parse_all(
|
|
'(defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
|
|
):
|
|
aser(expr, env)
|
|
|
|
case_expr = parse_all(
|
|
'(case "slug" "slug" (~broken :title "test") :else "index")'
|
|
)[0]
|
|
ctx = RequestContext()
|
|
|
|
with pytest.raises(Exception, match="Undefined symbol"):
|
|
asyncio.run(async_eval_slot_to_sx(case_expr, dict(env), ctx))
|