"""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))