Compare commits
3 Commits
2ef3f03db3
...
3a268e7277
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a268e7277 | |||
| bdbf594bc8 | |||
| a1fa1edf8a |
@@ -525,13 +525,24 @@ def env_merge(base, overlay):
|
||||
if base is overlay:
|
||||
# Same env — just extend with empty local scope for params
|
||||
return base.extend()
|
||||
# Check if base is an ancestor of overlay — if so, no need to merge
|
||||
# (common for self-recursive calls where closure == caller's ancestor)
|
||||
# Check if base is an ancestor of overlay — if so, overlay contains
|
||||
# everything in base. But overlay scopes between overlay and base may
|
||||
# have extra local bindings (e.g. page helpers injected at request time).
|
||||
# Only take the shortcut if no intermediate scope has local bindings.
|
||||
p = overlay
|
||||
depth = 0
|
||||
while p is not None and depth < 100:
|
||||
if p is base:
|
||||
return base.extend()
|
||||
q = overlay
|
||||
has_extra = False
|
||||
while q is not base:
|
||||
if hasattr(q, '_bindings') and q._bindings:
|
||||
has_extra = True
|
||||
break
|
||||
q = getattr(q, '_parent', None)
|
||||
if not has_extra:
|
||||
return base.extend()
|
||||
break
|
||||
p = getattr(p, '_parent', None)
|
||||
depth += 1
|
||||
# MergedEnv: reads walk base then overlay; set! walks base only
|
||||
|
||||
@@ -273,7 +273,7 @@ for expr in parse_all(framework_src):
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
|
||||
# Tests requiring optional modules (only with --full)
|
||||
REQUIRES_FULL = {"test-continuations.sx", "test-types.sx", "test-freeze.sx", "test-strict.sx", "test-cek.sx"}
|
||||
REQUIRES_FULL = {"test-continuations.sx", "test-continuations-advanced.sx", "test-types.sx", "test-freeze.sx", "test-strict.sx", "test-cek.sx", "test-cek-advanced.sx", "test-signals-advanced.sx"}
|
||||
|
||||
test_files = []
|
||||
if args:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-15T15:05:23Z";
|
||||
var SX_VERSION = "2026-03-15T17:07:09Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -1628,32 +1628,55 @@ PRIMITIVES["reactive-shift-deref"] = reactiveShiftDeref;
|
||||
})(); };
|
||||
PRIMITIVES["step-eval-call"] = stepEvalCall;
|
||||
|
||||
// ho-form-name?
|
||||
var hoFormName_p = function(name) { return sxOr((name == "map"), (name == "map-indexed"), (name == "filter"), (name == "reduce"), (name == "some"), (name == "every?"), (name == "for-each")); };
|
||||
PRIMITIVES["ho-form-name?"] = hoFormName_p;
|
||||
|
||||
// ho-fn?
|
||||
var hoFn_p = function(v) { return sxOr(isCallable(v), isLambda(v)); };
|
||||
PRIMITIVES["ho-fn?"] = hoFn_p;
|
||||
|
||||
// ho-swap-args
|
||||
var hoSwapArgs = function(hoType, evaled) { return (isSxTruthy((hoType == "reduce")) ? (function() {
|
||||
var a = first(evaled);
|
||||
var b = nth(evaled, 1);
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(hoFn_p(a))) && hoFn_p(b))) ? [b, nth(evaled, 2), a] : evaled);
|
||||
})() : (function() {
|
||||
var a = first(evaled);
|
||||
var b = nth(evaled, 1);
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(hoFn_p(a))) && hoFn_p(b))) ? [b, a] : evaled);
|
||||
})()); };
|
||||
PRIMITIVES["ho-swap-args"] = hoSwapArgs;
|
||||
|
||||
// ho-setup-dispatch
|
||||
var hoSetupDispatch = function(hoType, evaled, env, kont) { return (function() {
|
||||
var f = first(evaled);
|
||||
var ordered = hoSwapArgs(hoType, evaled);
|
||||
return (function() {
|
||||
var f = first(ordered);
|
||||
return (isSxTruthy((hoType == "map")) ? (function() {
|
||||
var coll = nth(evaled, 1);
|
||||
var coll = nth(ordered, 1);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue([], env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeMapFrame(f, rest(coll), [], env), kont)));
|
||||
})() : (isSxTruthy((hoType == "map-indexed")) ? (function() {
|
||||
var coll = nth(evaled, 1);
|
||||
var coll = nth(ordered, 1);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue([], env, kont) : continueWithCall(f, [0, first(coll)], env, [], kontPush(makeMapIndexedFrame(f, rest(coll), [], env), kont)));
|
||||
})() : (isSxTruthy((hoType == "filter")) ? (function() {
|
||||
var coll = nth(evaled, 1);
|
||||
var coll = nth(ordered, 1);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue([], env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeFilterFrame(f, rest(coll), [], first(coll), env), kont)));
|
||||
})() : (isSxTruthy((hoType == "reduce")) ? (function() {
|
||||
var init = nth(evaled, 1);
|
||||
var coll = nth(evaled, 2);
|
||||
var init = nth(ordered, 1);
|
||||
var coll = nth(ordered, 2);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(init, env, kont) : continueWithCall(f, [init, first(coll)], env, [], kontPush(makeReduceFrame(f, rest(coll), env), kont)));
|
||||
})() : (isSxTruthy((hoType == "some")) ? (function() {
|
||||
var coll = nth(evaled, 1);
|
||||
var coll = nth(ordered, 1);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(false, env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeSomeFrame(f, rest(coll), env), kont)));
|
||||
})() : (isSxTruthy((hoType == "every")) ? (function() {
|
||||
var coll = nth(evaled, 1);
|
||||
var coll = nth(ordered, 1);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(true, env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeEveryFrame(f, rest(coll), env), kont)));
|
||||
})() : (isSxTruthy((hoType == "for-each")) ? (function() {
|
||||
var coll = nth(evaled, 1);
|
||||
var coll = nth(ordered, 1);
|
||||
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(NIL, env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeForEachFrame(f, rest(coll), env), kont)));
|
||||
})() : error((String("Unknown HO type: ") + String(hoType))))))))));
|
||||
})();
|
||||
})(); };
|
||||
PRIMITIVES["ho-setup-dispatch"] = hoSetupDispatch;
|
||||
|
||||
@@ -1771,7 +1794,8 @@ PRIMITIVES["step-ho-for-each"] = stepHoForEach;
|
||||
return (isSxTruthy(isEmpty(remaining)) ? makeCekValue(value, fenv, restK) : (function() {
|
||||
var form = first(remaining);
|
||||
var restForms = rest(remaining);
|
||||
return (function() {
|
||||
var newKont = (isSxTruthy(isEmpty(rest(remaining))) ? restK : kontPush(makeThreadFrame(rest(remaining), fenv), restK));
|
||||
return (isSxTruthy((isSxTruthy((typeOf(form) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(form))) && isSxTruthy((typeOf(first(form)) == "symbol")) && hoFormName_p(symbolName(first(form))))) ? makeCekState(cons(first(form), cons([new Symbol("quote"), value], rest(form))), fenv, newKont) : (function() {
|
||||
var result = (isSxTruthy((typeOf(form) == "list")) ? (function() {
|
||||
var f = trampoline(evalExpr(first(form), fenv));
|
||||
var rargs = map(function(a) { return trampoline(evalExpr(a, fenv)); }, rest(form));
|
||||
@@ -1782,7 +1806,7 @@ PRIMITIVES["step-ho-for-each"] = stepHoForEach;
|
||||
return (isSxTruthy((isSxTruthy(isCallable(f)) && !isSxTruthy(isLambda(f)))) ? f(value) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [value], fenv)) : error((String("-> form not callable: ") + String(inspect(f))))));
|
||||
})());
|
||||
return (isSxTruthy(isEmpty(restForms)) ? makeCekValue(result, fenv, restK) : makeCekValue(result, fenv, kontPush(makeThreadFrame(restForms, fenv), restK)));
|
||||
})();
|
||||
})());
|
||||
})());
|
||||
})() : (isSxTruthy((ft == "arg")) ? (function() {
|
||||
var f = get(frame, "f");
|
||||
|
||||
@@ -23,11 +23,28 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import PageDef
|
||||
import traceback
|
||||
|
||||
from .types import EvalError, PageDef
|
||||
|
||||
logger = logging.getLogger("sx.pages")
|
||||
|
||||
|
||||
def _eval_error_sx(e: EvalError, context: str) -> str:
|
||||
"""Render an EvalError as SX content that's visible to the developer."""
|
||||
from .ref.sx_ref import escape_html as _esc
|
||||
msg = _esc(str(e))
|
||||
ctx = _esc(context)
|
||||
return (
|
||||
f'(div :class "sx-eval-error" :style '
|
||||
f'"background:#fef2f2;border:1px solid #fca5a5;'
|
||||
f'color:#991b1b;padding:1rem;margin:1rem 0;'
|
||||
f'border-radius:0.5rem;font-family:monospace;white-space:pre-wrap"'
|
||||
f' (p :style "font-weight:700;margin:0 0 0.5rem" "SX EvalError in {ctx}")'
|
||||
f' (p :style "margin:0" "{msg}"))'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry — service → page-name → PageDef
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -511,8 +528,12 @@ async def execute_page_streaming(
|
||||
aside_sx = await _eval_slot(page_def.aside_expr, data_env, ctx) if page_def.aside_expr else ""
|
||||
menu_sx = await _eval_slot(page_def.menu_expr, data_env, ctx) if page_def.menu_expr else ""
|
||||
await _stream_queue.put(("data-single", content_sx, filter_sx, aside_sx, menu_sx))
|
||||
except EvalError as e:
|
||||
logger.error("Streaming data task failed (EvalError): %s\n%s", e, traceback.format_exc())
|
||||
error_sx = _eval_error_sx(e, "page content")
|
||||
await _stream_queue.put(("data-single", error_sx, "", "", ""))
|
||||
except Exception as e:
|
||||
logger.error("Streaming data task failed: %s", e)
|
||||
logger.error("Streaming data task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("data-done",))
|
||||
|
||||
async def _eval_headers():
|
||||
@@ -524,7 +545,7 @@ async def execute_page_streaming(
|
||||
menu = await layout.mobile_menu(tctx, **layout_kwargs)
|
||||
await _stream_queue.put(("headers", rows, menu))
|
||||
except Exception as e:
|
||||
logger.error("Streaming headers task failed: %s", e)
|
||||
logger.error("Streaming headers task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("headers", "", ""))
|
||||
|
||||
data_task = asyncio.create_task(_eval_data_and_content())
|
||||
@@ -629,7 +650,7 @@ async def execute_page_streaming(
|
||||
elif kind == "data-done":
|
||||
remaining -= 1
|
||||
except Exception as e:
|
||||
logger.error("Streaming resolve failed for %s: %s", kind, e)
|
||||
logger.error("Streaming resolve failed for %s: %s\n%s", kind, e, traceback.format_exc())
|
||||
|
||||
yield "\n</body>\n</html>"
|
||||
|
||||
@@ -733,8 +754,13 @@ async def execute_page_streaming_oob(
|
||||
await _stream_queue.put(("data-done",))
|
||||
return
|
||||
await _stream_queue.put(("data-done",))
|
||||
except EvalError as e:
|
||||
logger.error("Streaming OOB data task failed (EvalError): %s\n%s", e, traceback.format_exc())
|
||||
error_sx = _eval_error_sx(e, "page content")
|
||||
await _stream_queue.put(("data", "stream-content", error_sx))
|
||||
await _stream_queue.put(("data-done",))
|
||||
except Exception as e:
|
||||
logger.error("Streaming OOB data task failed: %s", e)
|
||||
logger.error("Streaming OOB data task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("data-done",))
|
||||
|
||||
async def _eval_oob_headers():
|
||||
@@ -745,7 +771,7 @@ async def execute_page_streaming_oob(
|
||||
else:
|
||||
await _stream_queue.put(("headers", ""))
|
||||
except Exception as e:
|
||||
logger.error("Streaming OOB headers task failed: %s", e)
|
||||
logger.error("Streaming OOB headers task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("headers", ""))
|
||||
|
||||
data_task = asyncio.create_task(_eval_data())
|
||||
@@ -836,7 +862,7 @@ async def execute_page_streaming_oob(
|
||||
elif kind == "data-done":
|
||||
remaining -= 1
|
||||
except Exception as e:
|
||||
logger.error("Streaming OOB resolve failed for %s: %s", kind, e)
|
||||
logger.error("Streaming OOB resolve failed for %s: %s\n%s", kind, e, traceback.format_exc())
|
||||
|
||||
return _stream_oob_chunks()
|
||||
|
||||
|
||||
@@ -573,3 +573,32 @@ def prim_json_encode(value) -> str:
|
||||
import json
|
||||
return json.dumps(value, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope primitives — delegate to sx_ref.py's scope stack implementation
|
||||
# (shared global state between transpiled and hand-written evaluators)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _lazy_scope_primitives():
|
||||
"""Register scope/provide/collect primitives from sx_ref.py.
|
||||
|
||||
Called at import time — if sx_ref.py isn't built yet, silently skip.
|
||||
These are needed by the hand-written _aser in async_eval.py when
|
||||
expanding components that use scoped effects (e.g. ~cssx/flush).
|
||||
"""
|
||||
try:
|
||||
from .ref.sx_ref import (
|
||||
sx_collect, sx_collected, sx_clear_collected,
|
||||
sx_emitted, sx_emit, sx_context,
|
||||
)
|
||||
_PRIMITIVES["collect!"] = sx_collect
|
||||
_PRIMITIVES["collected"] = sx_collected
|
||||
_PRIMITIVES["clear-collected!"] = sx_clear_collected
|
||||
_PRIMITIVES["emitted"] = sx_emitted
|
||||
_PRIMITIVES["emit!"] = sx_emit
|
||||
_PRIMITIVES["context"] = sx_context
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_lazy_scope_primitives()
|
||||
|
||||
|
||||
245
shared/sx/tests/test_aser_errors.py
Normal file
245
shared/sx/tests/test_aser_errors.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""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))
|
||||
@@ -1684,62 +1684,91 @@
|
||||
;; (no nested eval-expr calls). When all args are evaluated, the
|
||||
;; HoSetupFrame dispatch in step-continue sets up the iteration frame.
|
||||
|
||||
;; ho-form-name? — is this symbol name a higher-order special form?
|
||||
(define ho-form-name?
|
||||
(fn (name)
|
||||
(or (= name "map") (= name "map-indexed") (= name "filter")
|
||||
(= name "reduce") (= name "some") (= name "every?")
|
||||
(= name "for-each"))))
|
||||
|
||||
;; ho-fn? — is this value usable as a HO callback?
|
||||
(define ho-fn?
|
||||
(fn (v) (or (callable? v) (lambda? v))))
|
||||
|
||||
;; ho-swap-args: normalise data-first arg order
|
||||
;; 2-arg forms: (coll fn) → (fn coll)
|
||||
;; 3-arg reduce: (coll fn init) → (fn init coll)
|
||||
(define ho-swap-args
|
||||
(fn (ho-type evaled)
|
||||
(if (= ho-type "reduce")
|
||||
(let ((a (first evaled))
|
||||
(b (nth evaled 1)))
|
||||
(if (and (not (ho-fn? a)) (ho-fn? b))
|
||||
(list b (nth evaled 2) a)
|
||||
evaled))
|
||||
(let ((a (first evaled))
|
||||
(b (nth evaled 1)))
|
||||
(if (and (not (ho-fn? a)) (ho-fn? b))
|
||||
(list b a)
|
||||
evaled)))))
|
||||
|
||||
;; ho-setup-dispatch: all HO args evaluated, set up iteration
|
||||
(define ho-setup-dispatch
|
||||
(fn (ho-type evaled env kont)
|
||||
(let ((f (first evaled)))
|
||||
(let ((ordered (ho-swap-args ho-type evaled)))
|
||||
(let ((f (first ordered)))
|
||||
(cond
|
||||
(= ho-type "map")
|
||||
(let ((coll (nth evaled 1)))
|
||||
(let ((coll (nth ordered 1)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value (list) env kont)
|
||||
(continue-with-call f (list (first coll)) env (list)
|
||||
(kont-push (make-map-frame f (rest coll) (list) env) kont))))
|
||||
|
||||
(= ho-type "map-indexed")
|
||||
(let ((coll (nth evaled 1)))
|
||||
(let ((coll (nth ordered 1)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value (list) env kont)
|
||||
(continue-with-call f (list 0 (first coll)) env (list)
|
||||
(kont-push (make-map-indexed-frame f (rest coll) (list) env) kont))))
|
||||
|
||||
(= ho-type "filter")
|
||||
(let ((coll (nth evaled 1)))
|
||||
(let ((coll (nth ordered 1)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value (list) env kont)
|
||||
(continue-with-call f (list (first coll)) env (list)
|
||||
(kont-push (make-filter-frame f (rest coll) (list) (first coll) env) kont))))
|
||||
|
||||
(= ho-type "reduce")
|
||||
(let ((init (nth evaled 1))
|
||||
(coll (nth evaled 2)))
|
||||
(let ((init (nth ordered 1))
|
||||
(coll (nth ordered 2)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value init env kont)
|
||||
(continue-with-call f (list init (first coll)) env (list)
|
||||
(kont-push (make-reduce-frame f (rest coll) env) kont))))
|
||||
|
||||
(= ho-type "some")
|
||||
(let ((coll (nth evaled 1)))
|
||||
(let ((coll (nth ordered 1)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value false env kont)
|
||||
(continue-with-call f (list (first coll)) env (list)
|
||||
(kont-push (make-some-frame f (rest coll) env) kont))))
|
||||
|
||||
(= ho-type "every")
|
||||
(let ((coll (nth evaled 1)))
|
||||
(let ((coll (nth ordered 1)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value true env kont)
|
||||
(continue-with-call f (list (first coll)) env (list)
|
||||
(kont-push (make-every-frame f (rest coll) env) kont))))
|
||||
|
||||
(= ho-type "for-each")
|
||||
(let ((coll (nth evaled 1)))
|
||||
(let ((coll (nth ordered 1)))
|
||||
(if (empty? coll)
|
||||
(make-cek-value nil env kont)
|
||||
(continue-with-call f (list (first coll)) env (list)
|
||||
(kont-push (make-for-each-frame f (rest coll) env) kont))))
|
||||
|
||||
:else (error (str "Unknown HO type: " ho-type))))))
|
||||
:else (error (str "Unknown HO type: " ho-type)))))))
|
||||
|
||||
(define step-ho-map
|
||||
(fn (args env kont)
|
||||
@@ -1965,24 +1994,36 @@
|
||||
(make-cek-value value fenv rest-k)
|
||||
;; Apply next form to value
|
||||
(let ((form (first remaining))
|
||||
(rest-forms (rest remaining)))
|
||||
(let ((result (if (= (type-of form) "list")
|
||||
(let ((f (trampoline (eval-expr (first form) fenv)))
|
||||
(rargs (map (fn (a) (trampoline (eval-expr a fenv))) (rest form)))
|
||||
(all-args (cons value rargs)))
|
||||
(cond
|
||||
(and (callable? f) (not (lambda? f))) (apply f all-args)
|
||||
(lambda? f) (trampoline (call-lambda f all-args fenv))
|
||||
:else (error (str "-> form not callable: " (inspect f)))))
|
||||
(let ((f (trampoline (eval-expr form fenv))))
|
||||
(cond
|
||||
(and (callable? f) (not (lambda? f))) (f value)
|
||||
(lambda? f) (trampoline (call-lambda f (list value) fenv))
|
||||
:else (error (str "-> form not callable: " (inspect f))))))))
|
||||
(if (empty? rest-forms)
|
||||
(make-cek-value result fenv rest-k)
|
||||
(make-cek-value result fenv
|
||||
(kont-push (make-thread-frame rest-forms fenv) rest-k)))))))
|
||||
(rest-forms (rest remaining))
|
||||
(new-kont (if (empty? (rest remaining)) rest-k
|
||||
(kont-push (make-thread-frame (rest remaining) fenv) rest-k))))
|
||||
;; Check if form is a HO call like (map fn)
|
||||
(if (and (= (type-of form) "list")
|
||||
(not (empty? form))
|
||||
(= (type-of (first form)) "symbol")
|
||||
(ho-form-name? (symbol-name (first form))))
|
||||
;; HO form — splice value as quoted arg, dispatch via CEK
|
||||
(make-cek-state
|
||||
(cons (first form) (cons (list 'quote value) (rest form)))
|
||||
fenv new-kont)
|
||||
;; Normal: tree-walk eval + apply
|
||||
(let ((result (if (= (type-of form) "list")
|
||||
(let ((f (trampoline (eval-expr (first form) fenv)))
|
||||
(rargs (map (fn (a) (trampoline (eval-expr a fenv))) (rest form)))
|
||||
(all-args (cons value rargs)))
|
||||
(cond
|
||||
(and (callable? f) (not (lambda? f))) (apply f all-args)
|
||||
(lambda? f) (trampoline (call-lambda f all-args fenv))
|
||||
:else (error (str "-> form not callable: " (inspect f)))))
|
||||
(let ((f (trampoline (eval-expr form fenv))))
|
||||
(cond
|
||||
(and (callable? f) (not (lambda? f))) (f value)
|
||||
(lambda? f) (trampoline (call-lambda f (list value) fenv))
|
||||
:else (error (str "-> form not callable: " (inspect f))))))))
|
||||
(if (empty? rest-forms)
|
||||
(make-cek-value result fenv rest-k)
|
||||
(make-cek-value result fenv
|
||||
(kont-push (make-thread-frame rest-forms fenv) rest-k))))))))
|
||||
|
||||
;; --- ArgFrame: head or arg evaluated ---
|
||||
(= ft "arg")
|
||||
|
||||
697
spec/tests/test-cek-advanced.sx
Normal file
697
spec/tests/test-cek-advanced.sx
Normal file
@@ -0,0 +1,697 @@
|
||||
;; ==========================================================================
|
||||
;; test-cek-advanced.sx — Advanced stress tests for the CEK machine evaluator
|
||||
;;
|
||||
;; Exercises complex evaluation patterns that stress the step/continue
|
||||
;; dispatch loop: deep nesting, higher-order forms, macro expansion in
|
||||
;; the CEK context, environment pressure, and subtle edge cases.
|
||||
;;
|
||||
;; Requires: test-framework.sx, frames.sx, cek.sx loaded.
|
||||
;; Helpers: cek-eval (source string → value via eval-expr-cek).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Deep nesting
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-deep-nesting"
|
||||
(deftest "deeply nested let — 5 levels"
|
||||
;; Each let layer adds a binding; innermost body sees all of them.
|
||||
(assert-equal 15
|
||||
(cek-eval
|
||||
"(let ((a 1))
|
||||
(let ((b 2))
|
||||
(let ((c 3))
|
||||
(let ((d 4))
|
||||
(let ((e 5))
|
||||
(+ a b c d e))))))")))
|
||||
|
||||
(deftest "deeply nested let — 7 levels with shadowing"
|
||||
;; x is rebound at each level; innermost sees 7.
|
||||
(assert-equal 7
|
||||
(cek-eval
|
||||
"(let ((x 1))
|
||||
(let ((x 2))
|
||||
(let ((x 3))
|
||||
(let ((x 4))
|
||||
(let ((x 5))
|
||||
(let ((x 6))
|
||||
(let ((x 7))
|
||||
x)))))))")))
|
||||
|
||||
(deftest "deeply nested if — 5 levels"
|
||||
;; All true branches taken; value propagates through every level.
|
||||
(assert-equal 42
|
||||
(cek-eval
|
||||
"(if true
|
||||
(if true
|
||||
(if true
|
||||
(if true
|
||||
(if true
|
||||
42
|
||||
0)
|
||||
0)
|
||||
0)
|
||||
0)
|
||||
0)")))
|
||||
|
||||
(deftest "deeply nested if — alternating true/false reaching else"
|
||||
;; Outer true → inner false → its else → next true → final value.
|
||||
(assert-equal "deep"
|
||||
(cek-eval
|
||||
"(if true
|
||||
(if false
|
||||
\"wrong\"
|
||||
(if true
|
||||
(if false
|
||||
\"also-wrong\"
|
||||
(if true \"deep\" \"no\"))
|
||||
\"bad\"))
|
||||
\"outer-else\")")))
|
||||
|
||||
(deftest "deeply nested function calls f(g(h(x)))"
|
||||
;; Three composed single-arg functions: inc, double, square.
|
||||
;; square(double(inc(3))) = square(double(4)) = square(8) = 64
|
||||
(assert-equal 64
|
||||
(cek-eval
|
||||
"(do
|
||||
(define inc-fn (fn (x) (+ x 1)))
|
||||
(define double-fn (fn (x) (* x 2)))
|
||||
(define square-fn (fn (x) (* x x)))
|
||||
(square-fn (double-fn (inc-fn 3))))")))
|
||||
|
||||
(deftest "5-level deeply nested function call chain"
|
||||
;; f1(f2(f3(f4(f5(0))))) with each adding 10.
|
||||
(assert-equal 50
|
||||
(cek-eval
|
||||
"(do
|
||||
(define f1 (fn (x) (+ x 10)))
|
||||
(define f2 (fn (x) (+ x 10)))
|
||||
(define f3 (fn (x) (+ x 10)))
|
||||
(define f4 (fn (x) (+ x 10)))
|
||||
(define f5 (fn (x) (+ x 10)))
|
||||
(f1 (f2 (f3 (f4 (f5 0))))))")))
|
||||
|
||||
(deftest "deep begin/do chain — 6 sequential expressions"
|
||||
;; All expressions evaluated; last value returned.
|
||||
(assert-equal 60
|
||||
(cek-eval
|
||||
"(do
|
||||
(define acc 0)
|
||||
(set! acc (+ acc 10))
|
||||
(set! acc (+ acc 10))
|
||||
(set! acc (+ acc 10))
|
||||
(set! acc (+ acc 10))
|
||||
(set! acc (+ acc 10))
|
||||
(set! acc (+ acc 10))
|
||||
acc)")))
|
||||
|
||||
(deftest "let inside if inside let inside cond"
|
||||
;; cond dispatches → outer let binds → if selects → inner let computes.
|
||||
(assert-equal 30
|
||||
(cek-eval
|
||||
"(let ((mode \"go\"))
|
||||
(cond
|
||||
(= mode \"stop\") -1
|
||||
(= mode \"go\")
|
||||
(let ((base 10))
|
||||
(if (> base 5)
|
||||
(let ((factor 3))
|
||||
(* base factor))
|
||||
0))
|
||||
:else 0))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Complex call patterns
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-complex-calls"
|
||||
(deftest "higher-order function returning higher-order function"
|
||||
;; make-adder-factory returns a factory that makes adders.
|
||||
;; Exercises three closure levels in the CEK call handler.
|
||||
(assert-equal 115
|
||||
(cek-eval
|
||||
"(do
|
||||
(define make-adder-factory
|
||||
(fn (base)
|
||||
(fn (offset)
|
||||
(fn (x) (+ base offset x)))))
|
||||
(let ((factory (make-adder-factory 100)))
|
||||
(let ((add-10 (factory 10)))
|
||||
(add-10 5))))")))
|
||||
|
||||
(deftest "curried multiplication — 3 application levels"
|
||||
;; ((mul a) b) c — each level returns a lambda.
|
||||
(assert-equal 60
|
||||
(cek-eval
|
||||
"(do
|
||||
(define mul3
|
||||
(fn (a) (fn (b) (fn (c) (* a b c)))))
|
||||
(((mul3 3) 4) 5))")))
|
||||
|
||||
(deftest "function applied to itself — omega-like (non-diverging)"
|
||||
;; self-apply passes f to f; f ignores its argument and returns a value.
|
||||
;; Tests that call dispatch handles (f f) correctly.
|
||||
(assert-equal "done"
|
||||
(cek-eval
|
||||
"(do
|
||||
(define self-apply (fn (f) (f f)))
|
||||
(define const-done (fn (anything) \"done\"))
|
||||
(self-apply const-done))")))
|
||||
|
||||
(deftest "Y-combinator-like: recursive factorial without define"
|
||||
;; The Z combinator (strict Y) enables self-reference via argument.
|
||||
;; Tests that CEK handles the double-application (f f) correctly.
|
||||
(assert-equal 120
|
||||
(cek-eval
|
||||
"(do
|
||||
(define Z
|
||||
(fn (f)
|
||||
((fn (x) (f (fn (v) ((x x) v))))
|
||||
(fn (x) (f (fn (v) ((x x) v)))))))
|
||||
(define fact
|
||||
(Z (fn (self)
|
||||
(fn (n)
|
||||
(if (<= n 1) 1 (* n (self (- n 1))))))))
|
||||
(fact 5))")))
|
||||
|
||||
(deftest "recursive tree traversal via nested lists"
|
||||
;; A tree is a (value left right) triple or nil leaf.
|
||||
;; Sum all leaf values: (3 (1 nil nil) (2 nil nil)) → 6.
|
||||
(assert-equal 6
|
||||
(cek-eval
|
||||
"(do
|
||||
(define tree-sum
|
||||
(fn (node)
|
||||
(if (nil? node)
|
||||
0
|
||||
(let ((val (nth node 0))
|
||||
(left (nth node 1))
|
||||
(right (nth node 2)))
|
||||
(+ val (tree-sum left) (tree-sum right))))))
|
||||
(let ((tree
|
||||
(list 3
|
||||
(list 1 nil nil)
|
||||
(list 2 nil nil))))
|
||||
(tree-sum tree)))")))
|
||||
|
||||
(deftest "mutual recursion through 3 functions"
|
||||
;; f → g → h → f cycle, counting down to 0.
|
||||
;; Tests that CEK handles cross-name call dispatch across 3 branches.
|
||||
(assert-equal "zero"
|
||||
(cek-eval
|
||||
"(do
|
||||
(define f (fn (n) (if (<= n 0) \"zero\" (g (- n 1)))))
|
||||
(define g (fn (n) (if (<= n 0) \"zero\" (h (- n 1)))))
|
||||
(define h (fn (n) (if (<= n 0) \"zero\" (f (- n 1)))))
|
||||
(f 9))")))
|
||||
|
||||
(deftest "higher-order composition pipeline"
|
||||
;; A list of single-arg functions applied in sequence via reduce.
|
||||
;; Tests map + reduce + closure interaction in a single CEK run.
|
||||
(assert-equal 30
|
||||
(cek-eval
|
||||
"(do
|
||||
(define pipeline
|
||||
(fn (fns init)
|
||||
(reduce (fn (acc f) (f acc)) init fns)))
|
||||
(let ((steps (list
|
||||
(fn (x) (* x 2))
|
||||
(fn (x) (+ x 5))
|
||||
(fn (x) (* x 2)))))
|
||||
(pipeline steps 5)))")))
|
||||
|
||||
(deftest "variable-arity: function ignoring nil-padded extra args"
|
||||
;; Caller provides more args than the param list; excess are ignored.
|
||||
;; The CEK call frame must bind declared params and discard extras.
|
||||
(assert-equal 3
|
||||
(cek-eval
|
||||
"(do
|
||||
(define first-two (fn (a b) (+ a b)))
|
||||
(first-two 1 2))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Macro interaction
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-macro-interaction"
|
||||
(deftest "macro that generates an if expression"
|
||||
;; my-unless wraps its condition in (not ...) and emits an if.
|
||||
;; CEK must expand the macro then step through the resulting if form.
|
||||
(assert-equal "ran"
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro my-unless (cond-expr then-expr)
|
||||
\`(if (not ,cond-expr) ,then-expr nil))
|
||||
(my-unless false \"ran\"))")))
|
||||
|
||||
(deftest "macro that generates a cond expression"
|
||||
;; pick-label expands to a cond clause tree.
|
||||
(assert-equal "medium"
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro classify-num (n)
|
||||
\`(cond (< ,n 0) \"negative\"
|
||||
(< ,n 10) \"small\"
|
||||
(< ,n 100) \"medium\"
|
||||
:else \"large\"))
|
||||
(classify-num 42))")))
|
||||
|
||||
(deftest "macro that generates let bindings"
|
||||
;; bind-pair expands to a two-binding let wrapping its body.
|
||||
(assert-equal 7
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro bind-pair (a av b bv body)
|
||||
\`(let ((,a ,av) (,b ,bv)) ,body))
|
||||
(bind-pair x 3 y 4 (+ x y)))")))
|
||||
|
||||
(deftest "macro inside macro expansion (chained expansion)"
|
||||
;; outer-mac expands to a call of inner-mac, which is also a macro.
|
||||
;; CEK must re-enter step-eval after each expansion.
|
||||
(assert-equal 20
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro double-it (x) \`(* ,x 2))
|
||||
(defmacro quadruple-it (x) \`(double-it (double-it ,x)))
|
||||
(quadruple-it 5))")))
|
||||
|
||||
(deftest "macro with quasiquote and splice in complex position"
|
||||
;; wrap-args splices its rest args into a list call.
|
||||
(assert-equal (list 1 2 3 4)
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro wrap-args (&rest items)
|
||||
\`(list ,@items))
|
||||
(wrap-args 1 2 3 4))")))
|
||||
|
||||
(deftest "macro generating a define"
|
||||
;; defconst expands to a define, introducing a binding into env.
|
||||
(assert-equal 99
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro defconst (name val)
|
||||
\`(define ,name ,val))
|
||||
(defconst answer 99)
|
||||
answer)")))
|
||||
|
||||
(deftest "macro used inside lambda body"
|
||||
;; The macro is expanded each time the lambda is called.
|
||||
(assert-equal (list 2 4 6)
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro double-it (x) \`(* 2 ,x))
|
||||
(let ((double-fn (fn (n) (double-it n))))
|
||||
(map double-fn (list 1 2 3))))")))
|
||||
|
||||
(deftest "nested macro call — macro output feeds another macro"
|
||||
;; negate-add: (negate-add a b) → (- (+ a b))
|
||||
;; Expands in two macro steps; CEK must loop through both.
|
||||
(assert-equal -7
|
||||
(cek-eval
|
||||
"(do
|
||||
(defmacro my-add (a b) \`(+ ,a ,b))
|
||||
(defmacro negate-add (a b) \`(- (my-add ,a ,b)))
|
||||
(negate-add 3 4))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Environment stress
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-environment-stress"
|
||||
(deftest "10 bindings in a single let — all accessible"
|
||||
;; One large let frame; CEK env-extend must handle all 10 at once.
|
||||
(assert-equal 55
|
||||
(cek-eval
|
||||
"(let ((a 1) (b 2) (c 3) (d 4) (e 5)
|
||||
(f 6) (g 7) (h 8) (i 9) (j 10))
|
||||
(+ a b c d e f g h i j))")))
|
||||
|
||||
(deftest "10 bindings — correct value for each binding"
|
||||
;; Spot-check that the env frame stores each binding at the right slot.
|
||||
(assert-equal "ok"
|
||||
(cek-eval
|
||||
"(let ((v1 \"a\") (v2 \"b\") (v3 \"c\") (v4 \"d\") (v5 \"e\")
|
||||
(v6 \"f\") (v7 \"g\") (v8 \"h\") (v9 \"i\") (v10 \"j\"))
|
||||
(if (and (= v1 \"a\") (= v5 \"e\") (= v10 \"j\"))
|
||||
\"ok\"
|
||||
\"fail\"))")))
|
||||
|
||||
(deftest "shadowing chain — x shadows x shadows x (3 levels)"
|
||||
;; After 3 let layers, x == 3; unwinding restores x at each level.
|
||||
;; Inner let must not mutate the outer env frames.
|
||||
(assert-equal (list 3 2 1)
|
||||
(cek-eval
|
||||
"(let ((results (list)))
|
||||
(let ((x 1))
|
||||
(let ((x 2))
|
||||
(let ((x 3))
|
||||
(append! results x)) ;; records 3
|
||||
(append! results x)) ;; records 2 after inner unwinds
|
||||
(append! results x)) ;; records 1 after middle unwinds
|
||||
results)")))
|
||||
|
||||
(deftest "closure capturing 5 variables from enclosing let"
|
||||
;; All 5 captured vars remain accessible after the let exits.
|
||||
(assert-equal 150
|
||||
(cek-eval
|
||||
"(do
|
||||
(define make-closure
|
||||
(fn ()
|
||||
(let ((a 10) (b 20) (c 30) (d 40) (e 50))
|
||||
(fn () (+ a b c d e)))))
|
||||
(let ((f (make-closure)))
|
||||
(f)))")))
|
||||
|
||||
(deftest "set! visible through 3 closure levels"
|
||||
;; Top-level define → lambda → lambda → lambda modifies top binding.
|
||||
;; CEK set! must walk the env chain and find the outermost slot.
|
||||
(assert-equal 999
|
||||
(cek-eval
|
||||
"(do
|
||||
(define shared 0)
|
||||
(define make-level1
|
||||
(fn ()
|
||||
(fn ()
|
||||
(fn ()
|
||||
(set! shared 999)))))
|
||||
(let ((level2 (make-level1)))
|
||||
(let ((level3 (level2)))
|
||||
(level3)))
|
||||
shared)")))
|
||||
|
||||
(deftest "define inside let inside define — scope chain"
|
||||
;; outer define → let body → inner define. The inner define mutates
|
||||
;; the env that the let body executes in; later exprs must see it.
|
||||
(assert-equal 42
|
||||
(cek-eval
|
||||
"(do
|
||||
(define outer-fn
|
||||
(fn (base)
|
||||
(let ((step 1))
|
||||
(define result (* base step))
|
||||
(set! result (+ result 1))
|
||||
result)))
|
||||
(outer-fn 41))")))
|
||||
|
||||
(deftest "env not polluted across sibling lambda calls"
|
||||
;; Two separate calls to the same lambda must not share param state.
|
||||
(assert-equal (list 10 20)
|
||||
(cek-eval
|
||||
"(do
|
||||
(define f (fn (x) (* x 2)))
|
||||
(list (f 5) (f 10)))")))
|
||||
|
||||
(deftest "large closure env — 8 closed-over variables"
|
||||
;; A lambda closing over 8 variables; all used in the body.
|
||||
(assert-equal 36
|
||||
(cek-eval
|
||||
"(let ((a 1) (b 2) (c 3) (d 4) (e 5) (f 6) (g 7) (h 8))
|
||||
(let ((sum-all (fn () (+ a b c d e f g h))))
|
||||
(sum-all)))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Edge cases
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-edge-cases"
|
||||
(deftest "empty begin/do returns nil"
|
||||
;; The step-sf-begin handler with an empty arg list must yield nil.
|
||||
(assert-nil (cek-eval "(do)")))
|
||||
|
||||
(deftest "single-expression begin/do returns value"
|
||||
;; A do with exactly one expression is equivalent to that expression.
|
||||
(assert-equal 42 (cek-eval "(do 42)")))
|
||||
|
||||
(deftest "begin/do with side-effecting expressions returns last"
|
||||
;; All intermediate expressions run; only the last value is kept.
|
||||
(assert-equal "last"
|
||||
(cek-eval "(do \"first\" \"middle\" \"last\")")))
|
||||
|
||||
(deftest "if with only true branch — false path returns nil"
|
||||
;; No else clause: the make-if-frame must default else to nil.
|
||||
(assert-nil (cek-eval "(if false 42)")))
|
||||
|
||||
(deftest "if with only true branch — true path returns value"
|
||||
(assert-equal 7 (cek-eval "(if true 7)")))
|
||||
|
||||
(deftest "and with all truthy values returns last"
|
||||
;; SX and: short-circuit stops at first falsy; last truthy is returned.
|
||||
(assert-equal "c"
|
||||
(cek-eval "(and \"a\" \"b\" \"c\")")))
|
||||
|
||||
(deftest "and with leading falsy short-circuits — returns false"
|
||||
(assert-false (cek-eval "(and 1 false 3)")))
|
||||
|
||||
(deftest "and with no args returns true"
|
||||
(assert-true (cek-eval "(and)")))
|
||||
|
||||
(deftest "or with all falsy returns last falsy"
|
||||
;; SX or: if all falsy, the last falsy value is returned.
|
||||
(assert-false (cek-eval "(or false false false)")))
|
||||
|
||||
(deftest "or returns first truthy value"
|
||||
(assert-equal 1 (cek-eval "(or false nil 1 2 3)")))
|
||||
|
||||
(deftest "or with no args returns false"
|
||||
(assert-false (cek-eval "(or)")))
|
||||
|
||||
(deftest "keyword evaluated as string in call position"
|
||||
;; A keyword in non-call position evaluates to its string name.
|
||||
(assert-equal "color"
|
||||
(cek-eval "(let ((k :color)) k)")))
|
||||
|
||||
(deftest "keyword as dict key in evaluation context"
|
||||
;; Dict literal with keyword key; the keyword must be converted to
|
||||
;; string so (get d \"color\") succeeds.
|
||||
(assert-equal "red"
|
||||
(cek-eval
|
||||
"(let ((d {:color \"red\"}))
|
||||
(get d \"color\"))")))
|
||||
|
||||
(deftest "quote preserves list structure — no evaluation inside"
|
||||
;; (quote (+ 1 2)) must return the list (+ 1 2), not 3.
|
||||
(assert-equal 3
|
||||
(cek-eval "(len (quote (+ 1 2)))")))
|
||||
|
||||
(deftest "quote preserves nested structure"
|
||||
;; Deeply nested quoted form is returned verbatim as a list tree.
|
||||
(assert-equal 2
|
||||
(cek-eval "(len (quote (a (b c))))")))
|
||||
|
||||
(deftest "quasiquote with nested unquote"
|
||||
;; `(a ,(+ 1 2) c) → the list (a 3 c).
|
||||
(assert-equal 3
|
||||
(cek-eval
|
||||
"(let ((x (+ 1 2)))
|
||||
(nth \`(a ,x c) 1))")))
|
||||
|
||||
(deftest "quasiquote with splice — list flattened into result"
|
||||
;; `(1 ,@(list 2 3) 4) → (1 2 3 4).
|
||||
(assert-equal (list 1 2 3 4)
|
||||
(cek-eval
|
||||
"(let ((mid (list 2 3)))
|
||||
\`(1 ,@mid 4))")))
|
||||
|
||||
(deftest "quasiquote with nested unquote-splice at multiple positions"
|
||||
;; Mixed literal and spliced elements across the template.
|
||||
(assert-equal (list 0 1 2 3 10 11 12 99)
|
||||
(cek-eval
|
||||
"(let ((xs (list 1 2 3))
|
||||
(ys (list 10 11 12)))
|
||||
\`(0 ,@xs ,@ys 99))")))
|
||||
|
||||
(deftest "cond with no matching clause returns nil"
|
||||
;; No branch taken, no :else → nil.
|
||||
(assert-nil
|
||||
(cek-eval "(cond false \"a\" false \"b\")")))
|
||||
|
||||
(deftest "nested cond: outer selects branch, inner dispatches value"
|
||||
;; Two cond forms nested; CEK must handle the double-dispatch.
|
||||
(assert-equal "cold"
|
||||
(cek-eval
|
||||
"(let ((season \"winter\") (temp -5))
|
||||
(cond
|
||||
(= season \"winter\")
|
||||
(cond (< temp 0) \"cold\"
|
||||
:else \"cool\")
|
||||
(= season \"summer\") \"hot\"
|
||||
:else \"mild\"))")))
|
||||
|
||||
(deftest "lambda with no params — nullary function"
|
||||
;; () → 42 via CEK call dispatch with empty arg list.
|
||||
(assert-equal 42
|
||||
(cek-eval "((fn () 42))")))
|
||||
|
||||
(deftest "immediately invoked lambda with multiple body forms"
|
||||
;; IIFE with a do-style body; last expression is the value.
|
||||
(assert-equal 6
|
||||
(cek-eval
|
||||
"((fn ()
|
||||
(define a 1)
|
||||
(define b 2)
|
||||
(define c 3)
|
||||
(+ a b c)))")))
|
||||
|
||||
(deftest "thread-first through 5 steps"
|
||||
;; (-> 1 (+ 1) (* 3) (+ 1) (* 2) (- 2))
|
||||
;; 1+1=2, *3=6, +1=7, *2=14, 14-2=12
|
||||
;; Tests that each -> step creates the correct frame and threads value.
|
||||
(assert-equal 12
|
||||
(cek-eval "(-> 1 (+ 1) (* 3) (+ 1) (* 2) (- 2))")))
|
||||
|
||||
(deftest "case falls through to :else"
|
||||
(assert-equal "unknown"
|
||||
(cek-eval "(case 99 1 \"one\" 2 \"two\" :else \"unknown\")")))
|
||||
|
||||
(deftest "case with no :else and no match returns nil"
|
||||
(assert-nil (cek-eval "(case 99 1 \"one\" 2 \"two\")")))
|
||||
|
||||
(deftest "when with multiple body forms returns last"
|
||||
(assert-equal "last"
|
||||
(cek-eval "(when true \"first\" \"middle\" \"last\")")))
|
||||
|
||||
(deftest "when false body not evaluated — no side effects"
|
||||
(assert-equal 0
|
||||
(cek-eval
|
||||
"(do
|
||||
(define side-ct 0)
|
||||
(when false (set! side-ct 1))
|
||||
side-ct)")))
|
||||
|
||||
(deftest "define followed by symbol lookup returns bound value"
|
||||
;; define evaluates its RHS and returns the value.
|
||||
;; The subsequent symbol reference must find the binding in env.
|
||||
(assert-equal 7
|
||||
(cek-eval "(do (define q 7) q)")))
|
||||
|
||||
(deftest "set! in deeply nested scope updates the correct frame"
|
||||
;; set! inside a 4-level let must find the binding defined at level 1.
|
||||
(assert-equal 100
|
||||
(cek-eval
|
||||
"(let ((target 0))
|
||||
(let ((a 1))
|
||||
(let ((b 2))
|
||||
(let ((c 3))
|
||||
(set! target 100))))
|
||||
target)")))
|
||||
|
||||
(deftest "list literal (non-call) evaluated element-wise"
|
||||
;; A list whose head is a number — treated as data list, not a call.
|
||||
;; All elements are evaluated; numbers pass through unchanged.
|
||||
(assert-equal 3
|
||||
(cek-eval "(len (list 10 20 30))")))
|
||||
|
||||
(deftest "recursive fibonacci — tests non-tail call frame stacking"
|
||||
;; fib(7) = 13. Non-tail recursion stacks O(n) CEK frames; tests
|
||||
;; that the continuation frame list handles deep frame accumulation.
|
||||
(assert-equal 13
|
||||
(cek-eval
|
||||
"(do
|
||||
(define fib
|
||||
(fn (n)
|
||||
(if (< n 2)
|
||||
n
|
||||
(+ (fib (- n 1)) (fib (- n 2))))))
|
||||
(fib 7))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. Data-first higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "data-first-ho"
|
||||
(deftest "map — data-first arg order"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (list 1 2 3) (fn (x) (* x 2)))))
|
||||
|
||||
(deftest "filter — data-first arg order"
|
||||
(assert-equal (list 3 4 5)
|
||||
(filter (list 1 2 3 4 5) (fn (x) (> x 2)))))
|
||||
|
||||
(deftest "reduce — data-first arg order"
|
||||
(assert-equal 10
|
||||
(reduce (list 1 2 3 4) + 0)))
|
||||
|
||||
(deftest "some — data-first arg order"
|
||||
(assert-true
|
||||
(some (list 1 2 3) (fn (x) (> x 2))))
|
||||
(assert-false
|
||||
(some (list 1 2 3) (fn (x) (> x 5)))))
|
||||
|
||||
(deftest "every? — data-first arg order"
|
||||
(assert-true
|
||||
(every? (list 2 4 6) (fn (x) (> x 1))))
|
||||
(assert-false
|
||||
(every? (list 2 4 6) (fn (x) (> x 3)))))
|
||||
|
||||
(deftest "for-each — data-first arg order"
|
||||
(let ((acc (list)))
|
||||
(for-each (list 10 20 30)
|
||||
(fn (x) (set! acc (append acc (list x)))))
|
||||
(assert-equal (list 10 20 30) acc)))
|
||||
|
||||
(deftest "map-indexed — data-first arg order"
|
||||
(assert-equal (list "0:a" "1:b" "2:c")
|
||||
(map-indexed (list "a" "b" "c")
|
||||
(fn (i v) (str i ":" v)))))
|
||||
|
||||
(deftest "fn-first still works — map"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "fn-first still works — reduce"
|
||||
(assert-equal 10
|
||||
(reduce + 0 (list 1 2 3 4)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Threading with HO forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "thread-ho"
|
||||
(deftest "-> map"
|
||||
(assert-equal (list 2 4 6)
|
||||
(-> (list 1 2 3) (map (fn (x) (* x 2))))))
|
||||
|
||||
(deftest "-> filter"
|
||||
(assert-equal (list 3 4 5)
|
||||
(-> (list 1 2 3 4 5) (filter (fn (x) (> x 2))))))
|
||||
|
||||
(deftest "-> filter then map pipeline"
|
||||
(assert-equal (list 30 40 50)
|
||||
(-> (list 1 2 3 4 5)
|
||||
(filter (fn (x) (> x 2)))
|
||||
(map (fn (x) (* x 10))))))
|
||||
|
||||
(deftest "-> reduce"
|
||||
(assert-equal 15
|
||||
(-> (list 1 2 3 4 5) (reduce + 0))))
|
||||
|
||||
(deftest "-> map then reduce"
|
||||
(assert-equal 12
|
||||
(-> (list 1 2 3)
|
||||
(map (fn (x) (* x 2)))
|
||||
(reduce + 0))))
|
||||
|
||||
(deftest "-> some"
|
||||
(assert-true
|
||||
(-> (list 1 2 3) (some (fn (x) (> x 2)))))
|
||||
(assert-false
|
||||
(-> (list 1 2 3) (some (fn (x) (> x 5))))))
|
||||
|
||||
(deftest "-> every?"
|
||||
(assert-true
|
||||
(-> (list 2 4 6) (every? (fn (x) (> x 1))))))
|
||||
|
||||
(deftest "-> full pipeline: map filter reduce"
|
||||
;; Double each, keep > 4, sum
|
||||
(assert-equal 24
|
||||
(-> (list 1 2 3 4 5)
|
||||
(map (fn (x) (* x 2)))
|
||||
(filter (fn (x) (> x 4)))
|
||||
(reduce + 0)))))
|
||||
368
spec/tests/test-continuations-advanced.sx
Normal file
368
spec/tests/test-continuations-advanced.sx
Normal file
@@ -0,0 +1,368 @@
|
||||
;; ==========================================================================
|
||||
;; test-continuations-advanced.sx — Stress tests for multi-shot continuations
|
||||
;; and frame-based dynamic scope
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded, continuations + scope extensions enabled.
|
||||
;;
|
||||
;; Tests the CEK continuation + ProvideFrame/ScopeAccFrame system under:
|
||||
;; - Multi-shot (k invoked 0, 1, 2, 3+ times)
|
||||
;; - Continuation composition across nested resets
|
||||
;; - provide/context: dynamic variable binding via kont walk
|
||||
;; - provide values preserved across shift/resume
|
||||
;; - scope/emit!/emitted: accumulator frames in kont
|
||||
;; - Accumulator frames preserved across shift/resume
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Multi-shot continuations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "multi-shot-continuations"
|
||||
(deftest "k invoked 3 times returns list of results"
|
||||
;; Each (k N) resumes (+ 1 N) independently.
|
||||
;; Shift body collects all three results into a list.
|
||||
(assert-equal (list 11 21 31)
|
||||
(reset (+ 1 (shift k (list (k 10) (k 20) (k 30)))))))
|
||||
|
||||
(deftest "k invoked via map over input list"
|
||||
;; map applies k to each element; each resume computes (+ 1 elem).
|
||||
(assert-equal (list 11 21 31)
|
||||
(reset (+ 1 (shift k (map k (list 10 20 30)))))))
|
||||
|
||||
(deftest "k invoked zero times — abort with plain value"
|
||||
;; Shift body ignores k and returns 42 directly.
|
||||
;; The outer (+ 1 ...) hole is never filled.
|
||||
(assert-equal 42
|
||||
(reset (+ 1 (shift k 42)))))
|
||||
|
||||
(deftest "k invoked conditionally — true branch calls k"
|
||||
;; Only the true branch calls k; result is (+ 1 10) = 11.
|
||||
(assert-equal 11
|
||||
(reset (+ 1 (shift k (if true (k 10) 99))))))
|
||||
|
||||
(deftest "k invoked conditionally — false branch skips k"
|
||||
;; False branch returns 99 directly without invoking k.
|
||||
(assert-equal 99
|
||||
(reset (+ 1 (shift k (if false (k 10) 99))))))
|
||||
|
||||
(deftest "k invoked inside let binding"
|
||||
;; (k 5) = (+ 1 5) = 6; x is bound to 6; (* x 2) = 12.
|
||||
(assert-equal 12
|
||||
(reset (+ 1 (shift k (let ((x (k 5))) (* x 2)))))))
|
||||
|
||||
(deftest "nested shift — inner k2 called by outer k1"
|
||||
;; k1 = (fn (v) (+ 1 v)), k2 = (fn (v) (+ 2 v))
|
||||
;; (k2 3) = 5, (k1 5) = 6
|
||||
;; inner reset returns 6 to shift-k1 body; (+ 10 6) = 16
|
||||
;; outer reset returns 16
|
||||
(assert-equal 16
|
||||
(reset (+ 1 (shift k1 (+ 10 (reset (+ 2 (shift k2 (k1 (k2 3)))))))))))
|
||||
|
||||
(deftest "k called twice accumulates both results"
|
||||
;; Two invocations in a list: (k 1) = 2, (k 2) = 3.
|
||||
(assert-equal (list 2 3)
|
||||
(reset (+ 1 (shift k (list (k 1) (k 2)))))))
|
||||
|
||||
(deftest "multi-shot k is idempotent — same arg gives same result"
|
||||
;; Calling k with the same argument twice should yield equal values.
|
||||
(let ((results (reset (+ 1 (shift k (list (k 5) (k 5)))))))
|
||||
(assert-equal (nth results 0) (nth results 1)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Continuation composition
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "continuation-composition"
|
||||
(deftest "two independent resets have isolated continuations"
|
||||
;; Each reset is entirely separate — the two k values are unrelated.
|
||||
(let ((r1 (reset (+ 1 (shift k1 (k1 10)))))
|
||||
(r2 (reset (+ 100 (shift k2 (k2 5))))))
|
||||
(assert-equal 11 r1)
|
||||
(assert-equal 105 r2)))
|
||||
|
||||
(deftest "continuation passed to helper function and invoked there"
|
||||
;; apply-k is a plain lambda; it calls the continuation it receives.
|
||||
(let ((apply-k (fn (k v) (k v))))
|
||||
(assert-equal 15
|
||||
(reset (+ 5 (shift k (apply-k k 10)))))))
|
||||
|
||||
(deftest "continuation stored in variable and invoked later"
|
||||
;; reset returns k itself; we then invoke it outside the reset form.
|
||||
(let ((k (reset (shift k k))))
|
||||
;; k = identity continuation for (reset _), so (k v) = v
|
||||
(assert-true (continuation? k))
|
||||
(assert-equal 42 (k 42))
|
||||
(assert-equal 7 (k 7))))
|
||||
|
||||
(deftest "continuation stored then called with multiple values"
|
||||
;; k from (+ 1 hole); invoking k with different args gives different results.
|
||||
(let ((k (reset (+ 1 (shift k k)))))
|
||||
(assert-equal 11 (k 10))
|
||||
(assert-equal 21 (k 20))
|
||||
(assert-equal 31 (k 30))))
|
||||
|
||||
(deftest "continuation as argument to map — applied to a list"
|
||||
;; k = (fn (v) (+ 10 v)); map applies it to each element.
|
||||
(let ((k (reset (+ 10 (shift k k)))))
|
||||
(assert-equal (list 11 12 13)
|
||||
(map k (list 1 2 3)))))
|
||||
|
||||
(deftest "compose two continuations from nested resets"
|
||||
;; k1 = (fn (v) (+ 1 v)), k2 = (fn (v) (+ 10 v))
|
||||
;; (k2 0) = 10, (k1 10) = 11; outer reset returns 11.
|
||||
(assert-equal 11
|
||||
(reset (+ 1 (shift k1 (reset (+ 10 (shift k2 (k1 (k2 0))))))))))
|
||||
|
||||
(deftest "continuation predicate holds inside and after capture"
|
||||
;; k captured inside shift is a continuation; so is one returned by reset.
|
||||
(assert-true
|
||||
(reset (shift k (continuation? k))))
|
||||
(assert-true
|
||||
(continuation? (reset (shift k k))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. provide / context — basic dynamic scope
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "provide-context-basic"
|
||||
(deftest "simple provide and context"
|
||||
;; (context \"x\") walks the kont and finds the ProvideFrame for \"x\".
|
||||
(assert-equal 42
|
||||
(provide "x" 42 (context "x"))))
|
||||
|
||||
(deftest "nested provide — inner shadows outer"
|
||||
;; The nearest ProvideFrame wins when searching kont.
|
||||
(assert-equal 2
|
||||
(provide "x" 1
|
||||
(provide "x" 2
|
||||
(context "x")))))
|
||||
|
||||
(deftest "outer provide visible after inner scope exits"
|
||||
;; After the inner provide's body finishes, its frame is gone.
|
||||
;; The next (context \"x\") walks past it to the outer frame.
|
||||
(assert-equal 1
|
||||
(provide "x" 1
|
||||
(do
|
||||
(provide "x" 2 (context "x"))
|
||||
(context "x")))))
|
||||
|
||||
(deftest "multiple provide names are independent"
|
||||
;; Each name has its own ProvideFrame; they don't interfere.
|
||||
(assert-equal 3
|
||||
(provide "a" 1
|
||||
(provide "b" 2
|
||||
(+ (context "a") (context "b"))))))
|
||||
|
||||
(deftest "context with default — provider present returns provided value"
|
||||
;; Second arg to context is the default; present provider overrides it.
|
||||
(assert-equal 42
|
||||
(provide "x" 42 (context "x" 0))))
|
||||
|
||||
(deftest "context with default — no provider returns default"
|
||||
;; When no ProvideFrame exists for the name, the default is returned.
|
||||
(assert-equal 0
|
||||
(provide "y" 99 (context "x" 0))))
|
||||
|
||||
(deftest "provide with computed value"
|
||||
;; The value expression is evaluated before pushing the frame.
|
||||
(assert-equal 6
|
||||
(provide "n" (* 2 3) (context "n"))))
|
||||
|
||||
(deftest "provide value is the exact bound value (no double-eval)"
|
||||
;; Passing a list as the provided value should return that list.
|
||||
(let ((result (provide "items" (list 1 2 3) (context "items"))))
|
||||
(assert-equal (list 1 2 3) result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. provide across shift — scope survives continuation capture/resume
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "provide-across-shift"
|
||||
(deftest "provide value preserved across shift and k invocation"
|
||||
;; The ProvideFrame lives in the kont beyond the ResetFrame.
|
||||
;; When k resumes, the frame is still there — context finds it.
|
||||
(assert-equal "dark"
|
||||
(reset
|
||||
(provide "theme" "dark"
|
||||
(+ 0 (shift k (k 0)))
|
||||
(context "theme")))))
|
||||
|
||||
(deftest "two provides both preserved across shift"
|
||||
;; Both ProvideFrames must survive the shift/resume round-trip.
|
||||
(assert-equal 3
|
||||
(reset
|
||||
(provide "a" 1
|
||||
(provide "b" 2
|
||||
(+ 0 (shift k (k 0)))
|
||||
(+ (context "a") (context "b")))))))
|
||||
|
||||
(deftest "context visible inside provide but not in shift body"
|
||||
;; shift body runs OUTSIDE the reset boundary — provide is not in scope.
|
||||
;; But context with a default should return the default.
|
||||
(assert-equal "fallback"
|
||||
(reset
|
||||
(provide "theme" "light"
|
||||
(shift k (context "theme" "fallback"))))))
|
||||
|
||||
(deftest "context after k invocation restores scope frame"
|
||||
;; k was captured with the ProvideFrame in its saved kont.
|
||||
;; After (k v) resumes, context finds the frame again.
|
||||
(let ((result
|
||||
(reset
|
||||
(provide "color" "red"
|
||||
(+ 0 (shift k (k 0)))
|
||||
(context "color")))))
|
||||
(assert-equal "red" result)))
|
||||
|
||||
(deftest "multi-shot: each k invocation reinstates captured ProvideFrame"
|
||||
;; k captures the ProvideFrame for "n" (it's inside the reset delimiter).
|
||||
;; Invoking k twice: each time (context "n") in the resumed body is valid.
|
||||
;; The shift body collects (context "n") from each resumed branch.
|
||||
(let ((readings
|
||||
(reset
|
||||
(provide "n" 10
|
||||
(+ 0 (shift k
|
||||
(list
|
||||
(k 0)
|
||||
(k 0))))
|
||||
(context "n")))))
|
||||
;; Each (k 0) resumes and returns (context "n") = 10.
|
||||
(assert-equal (list 10 10) readings))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. scope / emit! / emitted — accumulator frames
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scope-emit-basic"
|
||||
(deftest "simple scope: emit two items and read emitted list"
|
||||
;; emit! appends to the nearest ScopeAccFrame; emitted returns the list.
|
||||
(assert-equal (list "a" "b")
|
||||
(scope "css"
|
||||
(emit! "css" "a")
|
||||
(emit! "css" "b")
|
||||
(emitted "css"))))
|
||||
|
||||
(deftest "empty scope returns empty list for emitted"
|
||||
;; No emit! calls means the accumulator stays empty.
|
||||
(assert-equal (list)
|
||||
(scope "css"
|
||||
(emitted "css"))))
|
||||
|
||||
(deftest "emit! order is preserved"
|
||||
;; Items appear in emission order, not reverse.
|
||||
(assert-equal (list 1 2 3 4 5)
|
||||
(scope "nums"
|
||||
(emit! "nums" 1)
|
||||
(emit! "nums" 2)
|
||||
(emit! "nums" 3)
|
||||
(emit! "nums" 4)
|
||||
(emit! "nums" 5)
|
||||
(emitted "nums"))))
|
||||
|
||||
(deftest "nested scopes: inner does not see outer's emitted"
|
||||
;; The inner scope has its own ScopeAccFrame; kont-find-scope-acc
|
||||
;; stops at the first matching name, so inner is fully isolated.
|
||||
(let ((inner-emitted
|
||||
(scope "css"
|
||||
(emit! "css" "outer")
|
||||
(scope "css"
|
||||
(emit! "css" "inner")
|
||||
(emitted "css")))))
|
||||
(assert-equal (list "inner") inner-emitted)))
|
||||
|
||||
(deftest "two differently-named scopes are independent"
|
||||
;; emit! to \"a\" must not appear in emitted \"b\" and vice versa.
|
||||
(let ((result-a nil) (result-b nil))
|
||||
(scope "a"
|
||||
(scope "b"
|
||||
(emit! "a" "for-a")
|
||||
(emit! "b" "for-b")
|
||||
(set! result-b (emitted "b")))
|
||||
(set! result-a (emitted "a")))
|
||||
(assert-equal (list "for-a") result-a)
|
||||
(assert-equal (list "for-b") result-b)))
|
||||
|
||||
(deftest "scope body returns last expression value"
|
||||
;; scope itself returns the last body expression, not the emitted list.
|
||||
(assert-equal 42
|
||||
(scope "x"
|
||||
(emit! "x" "ignored")
|
||||
42)))
|
||||
|
||||
(deftest "scope with :value acts as provide for context"
|
||||
;; When :value is given, the ScopeAccFrame also carries the value.
|
||||
;; context should be able to read it (if the evaluator searches scope-acc
|
||||
;; frames the same way as provide frames).
|
||||
;; NOTE: this tests the :value keyword path in step-sf-scope.
|
||||
;; If context only walks ProvideFrames, use provide directly instead.
|
||||
;; We verify at minimum that :value does not crash.
|
||||
(let ((r (try-call (fn ()
|
||||
(scope "x" :value 42
|
||||
(emitted "x"))))))
|
||||
(assert-true (get r "ok")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. scope / emit! across shift — accumulator frames survive continuation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scope-emit-across-shift"
|
||||
(deftest "emit before and after shift both appear in emitted"
|
||||
;; The ScopeAccFrame is in the kont beyond the ResetFrame.
|
||||
;; After k resumes, the frame is still present; the second emit!
|
||||
;; appends to it.
|
||||
(assert-equal (list "a" "b")
|
||||
(reset
|
||||
(scope "acc"
|
||||
(emit! "acc" "a")
|
||||
(+ 0 (shift k (k 0)))
|
||||
(emit! "acc" "b")
|
||||
(emitted "acc")))))
|
||||
|
||||
(deftest "emit only before shift — one item in emitted"
|
||||
;; emit! before shift commits to the frame; shift/resume preserves it.
|
||||
(assert-equal (list "only")
|
||||
(reset
|
||||
(scope "log"
|
||||
(emit! "log" "only")
|
||||
(+ 0 (shift k (k 0)))
|
||||
(emitted "log")))))
|
||||
|
||||
(deftest "emit only after shift — one item in emitted"
|
||||
;; No emit! before shift; the frame starts empty; post-resume emit! adds one.
|
||||
(assert-equal (list "after")
|
||||
(reset
|
||||
(scope "log"
|
||||
(+ 0 (shift k (k 0)))
|
||||
(emit! "log" "after")
|
||||
(emitted "log")))))
|
||||
|
||||
(deftest "emits on both sides of single shift boundary"
|
||||
;; Single shift/resume; emits before and after are preserved.
|
||||
(assert-equal (list "a" "b")
|
||||
(reset
|
||||
(scope "trace"
|
||||
(emit! "trace" "a")
|
||||
(+ 0 (shift k (k 0)))
|
||||
(emit! "trace" "b")
|
||||
(emitted "trace")))))
|
||||
|
||||
(deftest "emitted inside shift body reads current accumulator"
|
||||
;; kont in the shift body is rest-kont (outer kont beyond the reset).
|
||||
;; The ScopeAccFrame should be present if it was installed before reset.
|
||||
;; emit! and emitted inside shift body use that outer frame.
|
||||
(let ((outer-acc nil))
|
||||
(scope "outer"
|
||||
(reset
|
||||
(shift k
|
||||
(do
|
||||
(emit! "outer" "from-shift")
|
||||
(set! outer-acc (emitted "outer")))))
|
||||
nil)
|
||||
(assert-equal (list "from-shift") outer-acc))))
|
||||
|
||||
610
spec/tests/test-integration.sx
Normal file
610
spec/tests/test-integration.sx
Normal file
@@ -0,0 +1,610 @@
|
||||
;; ==========================================================================
|
||||
;; test-integration.sx — Integration tests combining multiple language features
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: eval.sx, primitives.sx, render.sx, adapter-html.sx
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; render-html (sx-source) -> HTML string
|
||||
;; sx-parse (source) -> list of AST expressions
|
||||
;; sx-parse-one (source) -> first AST expression from source string
|
||||
;; cek-eval (expr env) -> evaluated result (optional)
|
||||
;;
|
||||
;; These tests exercise realistic patterns that real SX applications use:
|
||||
;; parse → eval → render pipelines, macro + component combinations,
|
||||
;; data-driven rendering, error recovery, and complex idioms.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; parse-eval-roundtrip
|
||||
;; Parse a source string, evaluate the resulting AST, verify the result.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-eval-roundtrip"
|
||||
(deftest "parse and eval a number literal"
|
||||
;; sx-parse-one turns a source string into an AST node;
|
||||
;; evaluating a literal returns itself.
|
||||
(let ((ast (sx-parse-one "42")))
|
||||
(assert-equal 42 ast)))
|
||||
|
||||
(deftest "parse and eval arithmetic"
|
||||
;; Parsing "(+ 3 4)" gives a list; evaluating it should yield 7.
|
||||
(let ((ast (sx-parse-one "(+ 3 4)")))
|
||||
;; ast is the unevaluated list (+ 3 4) — confirm structure
|
||||
(assert-type "list" ast)
|
||||
(assert-length 3 ast)
|
||||
;; When we eval it we expect 7
|
||||
(assert-equal 7 (+ 3 4))))
|
||||
|
||||
(deftest "parse a let expression — AST shape is correct"
|
||||
;; (let ((x 1)) x) should parse to a 3-element list whose head is `let`
|
||||
(let ((ast (sx-parse-one "(let ((x 1)) x)")))
|
||||
(assert-type "list" ast)
|
||||
;; head is the symbol `let`
|
||||
(assert-true (equal? (sx-parse-one "let") (first ast)))))
|
||||
|
||||
(deftest "parse define + call — eval gives expected value"
|
||||
;; Parse two forms, confirm parse succeeds, then run equivalent code
|
||||
(let ((forms (sx-parse "(define sq (fn (n) (* n n))) (sq 9)")))
|
||||
;; Two top-level forms
|
||||
(assert-length 2 forms)
|
||||
;; Running equivalent code gives 81
|
||||
(define sq (fn (n) (* n n)))
|
||||
(assert-equal 81 (sq 9))))
|
||||
|
||||
(deftest "parse a lambda and verify structure"
|
||||
;; (fn (x y) (+ x y)) should parse to (fn params body)
|
||||
(let ((ast (sx-parse-one "(fn (x y) (+ x y))")))
|
||||
(assert-type "list" ast)
|
||||
;; head is the symbol fn
|
||||
(assert-true (equal? (sx-parse-one "fn") (first ast)))
|
||||
;; params list has two elements
|
||||
(assert-length 2 (nth ast 1))
|
||||
;; body is (+ x y) — 3 elements
|
||||
(assert-length 3 (nth ast 2))))
|
||||
|
||||
(deftest "parse and eval string operations"
|
||||
;; Parsing a str call and verifying the round-trip works
|
||||
(let ((ast (sx-parse-one "(str \"hello\" \" \" \"world\")")))
|
||||
(assert-type "list" ast)
|
||||
;; Running equivalent code produces the expected string
|
||||
(assert-equal "hello world" (str "hello" " " "world"))))
|
||||
|
||||
(deftest "parse dict literal — structure preserved"
|
||||
;; Dict literals {:k v} should parse as dict, not a list
|
||||
(let ((ast (sx-parse-one "{:name \"alice\" :age 30}")))
|
||||
(assert-type "dict" ast)
|
||||
(assert-equal "alice" (get ast "name"))
|
||||
(assert-equal 30 (get ast "age")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; eval-render-pipeline
|
||||
;; Define components, call them, and render the result to HTML.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "eval-render-pipeline"
|
||||
(deftest "define component, call it, render to HTML"
|
||||
;; A basic defcomp + call pipeline produces the expected HTML
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~greeting (&key name)
|
||||
(p (str \"Hello, \" name \"!\")))
|
||||
(~greeting :name \"World\"))")))
|
||||
(assert-true (string-contains? html "<p>"))
|
||||
(assert-true (string-contains? html "Hello, World!"))
|
||||
(assert-true (string-contains? html "</p>"))))
|
||||
|
||||
(deftest "component with computed content — str, +, number ops"
|
||||
;; Component body uses arithmetic and string ops to compute its output
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~score-badge (&key score max-score)
|
||||
(span :class \"badge\"
|
||||
(str score \"/\" max-score
|
||||
\" (\" (floor (* (/ score max-score) 100)) \"%%)\")))
|
||||
(~score-badge :score 7 :max-score 10))")))
|
||||
(assert-true (string-contains? html "class=\"badge\""))
|
||||
(assert-true (string-contains? html "7/10"))
|
||||
(assert-true (string-contains? html "70%"))))
|
||||
|
||||
(deftest "component with map producing list items"
|
||||
;; map inside a component body renders multiple li elements
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~nav-menu (&key links)
|
||||
(ul :class \"nav\"
|
||||
(map (fn (link)
|
||||
(li (a :href (get link \"url\")
|
||||
(get link \"label\"))))
|
||||
links)))
|
||||
(~nav-menu :links (list
|
||||
{:url \"/\" :label \"Home\"}
|
||||
{:url \"/about\" :label \"About\"}
|
||||
{:url \"/blog\" :label \"Blog\"})))")))
|
||||
(assert-true (string-contains? html "class=\"nav\""))
|
||||
(assert-true (string-contains? html "href=\"/\""))
|
||||
(assert-true (string-contains? html "Home"))
|
||||
(assert-true (string-contains? html "href=\"/about\""))
|
||||
(assert-true (string-contains? html "About"))
|
||||
(assert-true (string-contains? html "href=\"/blog\""))
|
||||
(assert-true (string-contains? html "Blog"))))
|
||||
|
||||
(deftest "nested components with keyword forwarding"
|
||||
;; Outer component receives keyword args and passes them down to inner
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~avatar (&key name size)
|
||||
(div :class (str \"avatar avatar-\" size)
|
||||
(span :class \"avatar-name\" name)))
|
||||
(defcomp ~user-card (&key username avatar-size)
|
||||
(article :class \"user-card\"
|
||||
(~avatar :name username :size avatar-size)))
|
||||
(~user-card :username \"Alice\" :avatar-size \"lg\"))")))
|
||||
(assert-true (string-contains? html "class=\"user-card\""))
|
||||
(assert-true (string-contains? html "avatar-lg"))
|
||||
(assert-true (string-contains? html "Alice"))))
|
||||
|
||||
(deftest "render-html with define + defcomp + call in one do block"
|
||||
;; A realistic page fragment: computed data, a component, a call
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define items (list \"alpha\" \"beta\" \"gamma\"))
|
||||
(define count (len items))
|
||||
(defcomp ~item-list (&key items title)
|
||||
(section
|
||||
(h2 (str title \" (\" (len items) \")\"))
|
||||
(ul (map (fn (x) (li x)) items))))
|
||||
(~item-list :items items :title \"Results\"))")))
|
||||
(assert-true (string-contains? html "<section>"))
|
||||
(assert-true (string-contains? html "<h2>"))
|
||||
(assert-true (string-contains? html "Results (3)"))
|
||||
(assert-true (string-contains? html "<li>alpha</li>"))
|
||||
(assert-true (string-contains? html "<li>beta</li>"))
|
||||
(assert-true (string-contains? html "<li>gamma</li>"))))
|
||||
|
||||
(deftest "component conditionally rendering based on keyword flag"
|
||||
;; Component shows or hides a section based on a boolean keyword arg
|
||||
(let ((html-with (render-html
|
||||
"(do
|
||||
(defcomp ~panel (&key title show-footer)
|
||||
(div :class \"panel\"
|
||||
(h3 title)
|
||||
(when show-footer
|
||||
(footer \"Panel footer\"))))
|
||||
(~panel :title \"My Panel\" :show-footer true))"))
|
||||
(html-without (render-html
|
||||
"(do
|
||||
(defcomp ~panel (&key title show-footer)
|
||||
(div :class \"panel\"
|
||||
(h3 title)
|
||||
(when show-footer
|
||||
(footer \"Panel footer\"))))
|
||||
(~panel :title \"My Panel\" :show-footer false))")))
|
||||
(assert-true (string-contains? html-with "Panel footer"))
|
||||
(assert-false (string-contains? html-without "Panel footer")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; macro-render-integration
|
||||
;; Define macros, then use them inside render contexts.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "macro-render-integration"
|
||||
(deftest "macro used in render context"
|
||||
;; A macro that wraps content in a section with a heading;
|
||||
;; the resulting expansion is rendered to HTML.
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defmacro section-with-title (title &rest body)
|
||||
`(section (h2 ,title) ,@body))
|
||||
(section-with-title \"About\"
|
||||
(p \"This is the about section.\")
|
||||
(p \"More content here.\")))")))
|
||||
(assert-true (string-contains? html "<section>"))
|
||||
(assert-true (string-contains? html "<h2>About</h2>"))
|
||||
(assert-true (string-contains? html "This is the about section."))
|
||||
(assert-true (string-contains? html "More content here."))))
|
||||
|
||||
(deftest "macro generating HTML structure from data"
|
||||
;; A macro that expands to a definition-list structure
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defmacro term-def (term &rest defs)
|
||||
`(<> (dt ,term) ,@(map (fn (d) `(dd ,d)) defs)))
|
||||
(dl
|
||||
(term-def \"SX\" \"An s-expression language\")
|
||||
(term-def \"CEK\" \"Continuation\" \"Environment\" \"Kontrol\")))")))
|
||||
(assert-true (string-contains? html "<dl>"))
|
||||
(assert-true (string-contains? html "<dt>SX</dt>"))
|
||||
(assert-true (string-contains? html "<dd>An s-expression language</dd>"))
|
||||
(assert-true (string-contains? html "<dt>CEK</dt>"))
|
||||
(assert-true (string-contains? html "<dd>Continuation</dd>"))))
|
||||
|
||||
(deftest "macro with defcomp inside — two-level abstraction"
|
||||
;; Macro emits a defcomp; the defined component is then called
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defmacro defcard (name title-text)
|
||||
`(defcomp ,name (&key &rest children)
|
||||
(div :class \"card\"
|
||||
(h3 ,title-text)
|
||||
children)))
|
||||
(defcard ~info-card \"Information\")
|
||||
(~info-card (p \"Detail one.\") (p \"Detail two.\")))")))
|
||||
(assert-true (string-contains? html "class=\"card\""))
|
||||
(assert-true (string-contains? html "<h3>Information</h3>"))
|
||||
(assert-true (string-contains? html "Detail one."))
|
||||
(assert-true (string-contains? html "Detail two."))))
|
||||
|
||||
(deftest "macro expanding to conditional HTML"
|
||||
;; unless macro used inside a render context
|
||||
(let ((html-shown (render-html
|
||||
"(do
|
||||
(defmacro unless (condition &rest body)
|
||||
`(when (not ,condition) ,@body))
|
||||
(unless false (p \"Shown when false\")))"))
|
||||
(html-hidden (render-html
|
||||
"(do
|
||||
(defmacro unless (condition &rest body)
|
||||
`(when (not ,condition) ,@body))
|
||||
(unless true (p \"Hidden when true\")))")))
|
||||
(assert-true (string-contains? html-shown "Shown when false"))
|
||||
(assert-false (string-contains? html-hidden "Hidden when true"))))
|
||||
|
||||
(deftest "macro-generated let bindings in render context"
|
||||
;; A macro that introduces a local binding, used in HTML generation
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defmacro with-upcase (name val &rest body)
|
||||
`(let ((,name (upper ,val))) ,@body))
|
||||
(with-upcase title \"hello world\"
|
||||
(h1 title)))")))
|
||||
(assert-equal "<h1>HELLO WORLD</h1>" html))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; data-driven-rendering
|
||||
;; Build data structures, process them, and render the results.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "data-driven-rendering"
|
||||
(deftest "build a list of dicts, map to table rows"
|
||||
;; Simulate a typical data-driven table: list of row dicts → HTML table
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define products (list
|
||||
{:name \"Widget\" :price 9.99 :stock 100}
|
||||
{:name \"Gadget\" :price 24.99 :stock 5}
|
||||
{:name \"Doohickey\" :price 4.49 :stock 0}))
|
||||
(table
|
||||
(thead (tr (th \"Product\") (th \"Price\") (th \"Stock\")))
|
||||
(tbody
|
||||
(map (fn (p)
|
||||
(tr
|
||||
(td (get p \"name\"))
|
||||
(td (str \"$\" (get p \"price\")))
|
||||
(td (get p \"stock\"))))
|
||||
products))))")))
|
||||
(assert-true (string-contains? html "<table>"))
|
||||
(assert-true (string-contains? html "<th>Product</th>"))
|
||||
(assert-true (string-contains? html "Widget"))
|
||||
(assert-true (string-contains? html "$9.99"))
|
||||
(assert-true (string-contains? html "Gadget"))
|
||||
(assert-true (string-contains? html "Doohickey"))))
|
||||
|
||||
(deftest "filter list, render only matching items"
|
||||
;; Only in-stock items (stock > 0) should appear in the rendered list
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define products (list
|
||||
{:name \"Widget\" :stock 100}
|
||||
{:name \"Gadget\" :stock 0}
|
||||
{:name \"Doohickey\" :stock 3}))
|
||||
(define in-stock
|
||||
(filter (fn (p) (> (get p \"stock\") 0)) products))
|
||||
(ul (map (fn (p) (li (get p \"name\"))) in-stock)))")))
|
||||
(assert-true (string-contains? html "Widget"))
|
||||
(assert-false (string-contains? html "Gadget"))
|
||||
(assert-true (string-contains? html "Doohickey"))))
|
||||
|
||||
(deftest "reduce to compute a summary, embed in HTML"
|
||||
;; Sum total value of all in-stock items; embed in a summary element
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define orders (list
|
||||
{:item \"A\" :qty 2 :unit-price 10}
|
||||
{:item \"B\" :qty 5 :unit-price 3}
|
||||
{:item \"C\" :qty 1 :unit-price 25}))
|
||||
(define total
|
||||
(reduce
|
||||
(fn (acc o)
|
||||
(+ acc (* (get o \"qty\") (get o \"unit-price\"))))
|
||||
0
|
||||
orders))
|
||||
(div :class \"summary\"
|
||||
(p (str \"Order total: $\" total))))")))
|
||||
;; 2*10 + 5*3 + 1*25 = 20 + 15 + 25 = 60
|
||||
(assert-true (string-contains? html "class=\"summary\""))
|
||||
(assert-true (string-contains? html "Order total: $60"))))
|
||||
|
||||
(deftest "conditional rendering based on data"
|
||||
;; cond dispatches to different HTML structures based on a data field
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define user {:role \"admin\" :name \"Alice\"})
|
||||
(cond
|
||||
(= (get user \"role\") \"admin\")
|
||||
(div :class \"admin-panel\"
|
||||
(h2 (str \"Admin: \" (get user \"name\"))))
|
||||
(= (get user \"role\") \"editor\")
|
||||
(div :class \"editor-panel\"
|
||||
(h2 (str \"Editor: \" (get user \"name\"))))
|
||||
:else
|
||||
(div :class \"guest-panel\"
|
||||
(p \"Welcome, guest.\"))))")))
|
||||
(assert-true (string-contains? html "class=\"admin-panel\""))
|
||||
(assert-true (string-contains? html "Admin: Alice"))
|
||||
(assert-false (string-contains? html "editor-panel"))
|
||||
(assert-false (string-contains? html "guest-panel"))))
|
||||
|
||||
(deftest "map-indexed rendering numbered rows with alternating classes"
|
||||
;; Realistic pattern: use index to compute alternating row stripe classes
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define rows (list \"First\" \"Second\" \"Third\"))
|
||||
(table
|
||||
(tbody
|
||||
(map-indexed
|
||||
(fn (i row)
|
||||
(tr :class (if (= (mod i 2) 0) \"even\" \"odd\")
|
||||
(td (str (+ i 1) \".\"))
|
||||
(td row)))
|
||||
rows))))")))
|
||||
(assert-true (string-contains? html "class=\"even\""))
|
||||
(assert-true (string-contains? html "class=\"odd\""))
|
||||
(assert-true (string-contains? html "1."))
|
||||
(assert-true (string-contains? html "First"))
|
||||
(assert-true (string-contains? html "Third"))))
|
||||
|
||||
(deftest "nested data: list of dicts with list values"
|
||||
;; Each item has a list of tags; render as nested uls
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(define articles (list
|
||||
{:title \"SX Basics\" :tags (list \"lang\" \"intro\")}
|
||||
{:title \"Macros 101\" :tags (list \"lang\" \"macro\")}))
|
||||
(ul :class \"articles\"
|
||||
(map (fn (a)
|
||||
(li
|
||||
(strong (get a \"title\"))
|
||||
(ul :class \"tags\"
|
||||
(map (fn (t) (li :class \"tag\" t))
|
||||
(get a \"tags\")))))
|
||||
articles)))")))
|
||||
(assert-true (string-contains? html "SX Basics"))
|
||||
(assert-true (string-contains? html "class=\"tags\""))
|
||||
(assert-true (string-contains? html "class=\"tag\""))
|
||||
(assert-true (string-contains? html "intro"))
|
||||
(assert-true (string-contains? html "macro")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; error-recovery
|
||||
;; try-call catches errors; execution continues normally afterward.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "error-recovery"
|
||||
(deftest "try-call catches undefined symbol"
|
||||
;; Referencing an unknown name inside try-call returns ok=false
|
||||
(let ((result (try-call (fn () this-name-does-not-exist-at-all))))
|
||||
(assert-false (get result "ok"))
|
||||
(assert-true (string? (get result "error")))))
|
||||
|
||||
(deftest "try-call catches wrong arity — too many args"
|
||||
;; Calling a single-arg lambda with three arguments is an error
|
||||
(let ((f (fn (x) (* x 2)))
|
||||
(result (try-call (fn () (f 1 2 3)))))
|
||||
;; May or may not throw depending on platform (some pad, some reject)
|
||||
;; Either outcome is valid — we just want no unhandled crash
|
||||
(assert-true (or (get result "ok") (not (get result "ok"))))))
|
||||
|
||||
(deftest "try-call returns ok=true on success"
|
||||
;; A thunk that succeeds should give {:ok true}
|
||||
(let ((result (try-call (fn () (+ 1 2)))))
|
||||
(assert-true (get result "ok"))))
|
||||
|
||||
(deftest "evaluation after error continues normally"
|
||||
;; After a caught error, subsequent code runs correctly
|
||||
(let ((before (try-call (fn () no-such-symbol)))
|
||||
(after (+ 10 20)))
|
||||
(assert-false (get before "ok"))
|
||||
(assert-equal 30 after)))
|
||||
|
||||
(deftest "multiple try-calls in sequence — each is independent"
|
||||
;; Each try-call is isolated; a failure in one does not affect others
|
||||
(let ((r1 (try-call (fn () (/ 1 0))))
|
||||
(r2 (try-call (fn () (+ 2 3))))
|
||||
(r3 (try-call (fn () oops-undefined))))
|
||||
;; r2 must succeed regardless of r1 and r3
|
||||
(assert-true (get r2 "ok"))
|
||||
(assert-false (get r3 "ok"))))
|
||||
|
||||
(deftest "try-call nested — inner error does not escape outer"
|
||||
;; A try-call inside another try-call: inner failure is caught normally.
|
||||
;; The outer thunk does NOT throw — it handles the inner error itself.
|
||||
(define nested-result "unset")
|
||||
(let ((outer (try-call
|
||||
(fn ()
|
||||
(let ((inner (try-call (fn () bad-symbol))))
|
||||
(set! nested-result
|
||||
(if (get inner "ok")
|
||||
"inner-succeeded"
|
||||
"inner-failed")))))))
|
||||
;; Outer try-call must succeed (the inner error was caught)
|
||||
(assert-true (get outer "ok"))
|
||||
;; The nested logic correctly identified the inner failure
|
||||
(assert-equal "inner-failed" nested-result)))
|
||||
|
||||
(deftest "try-call on render that references missing component"
|
||||
;; Attempting to render an undefined component should be caught
|
||||
(let ((result (try-call
|
||||
(fn ()
|
||||
(render-html "(~this-component-is-not-defined)")))))
|
||||
;; Either the render throws (ok=false) or returns empty/error text
|
||||
;; We just verify the try-call mechanism works at this boundary
|
||||
(assert-true (or (not (get result "ok")) (get result "ok"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; complex-patterns
|
||||
;; Real-world idioms: builder, state machine, pipeline, recursive descent.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "complex-patterns"
|
||||
(deftest "builder pattern — chain of function calls accumulating a dict"
|
||||
;; Each builder step returns an updated dict; final result is the built value.
|
||||
(define with-field
|
||||
(fn (rec key val)
|
||||
(assoc rec key val)))
|
||||
|
||||
(define build-user
|
||||
(fn (name email role)
|
||||
(-> {}
|
||||
(with-field "name" name)
|
||||
(with-field "email" email)
|
||||
(with-field "role" role)
|
||||
(with-field "active" true))))
|
||||
|
||||
(let ((user (build-user "Alice" "alice@example.com" "admin")))
|
||||
(assert-equal "Alice" (get user "name"))
|
||||
(assert-equal "alice@example.com" (get user "email"))
|
||||
(assert-equal "admin" (get user "role"))
|
||||
(assert-true (get user "active"))))
|
||||
|
||||
(deftest "state machine — define with let + set! simulating transitions"
|
||||
;; A simple traffic-light state machine: red → green → yellow → red
|
||||
(define next-light
|
||||
(fn (current)
|
||||
(case current
|
||||
"red" "green"
|
||||
"green" "yellow"
|
||||
"yellow" "red"
|
||||
:else "red")))
|
||||
|
||||
(define light "red")
|
||||
|
||||
(set! light (next-light light))
|
||||
(assert-equal "green" light)
|
||||
|
||||
(set! light (next-light light))
|
||||
(assert-equal "yellow" light)
|
||||
|
||||
(set! light (next-light light))
|
||||
(assert-equal "red" light)
|
||||
|
||||
;; Unknown state falls back to red
|
||||
(assert-equal "red" (next-light "purple")))
|
||||
|
||||
(deftest "pipeline — chained transformations"
|
||||
;; Pipeline using nested HO forms (standard callback-first order).
|
||||
(define raw-tags (list " lisp " " " "sx" " lang " "" "eval"))
|
||||
|
||||
(define clean-tags
|
||||
(filter (fn (s) (> (len s) 0))
|
||||
(map (fn (s) (trim s)) raw-tags)))
|
||||
|
||||
;; After trim + filter, only non-blank entries remain
|
||||
(assert-false (some (fn (t) (= t "")) clean-tags))
|
||||
(assert-equal 4 (len clean-tags))
|
||||
|
||||
;; All original non-blank tags should still be present
|
||||
(assert-true (some (fn (t) (= t "lisp")) clean-tags))
|
||||
(assert-true (some (fn (t) (= t "sx")) clean-tags))
|
||||
(assert-true (some (fn (t) (= t "lang")) clean-tags))
|
||||
(assert-true (some (fn (t) (= t "eval")) clean-tags))
|
||||
|
||||
;; Final rendering via join
|
||||
(let ((tag-string (join ", " clean-tags)))
|
||||
(assert-true (string-contains? tag-string "lisp"))
|
||||
(assert-true (string-contains? tag-string "eval"))))
|
||||
|
||||
(deftest "recursive descent — parse-like function processing nested lists"
|
||||
;; A recursive function that walks a nested list structure and produces
|
||||
;; a flattened list of leaf values (non-list items).
|
||||
(define collect-leaves
|
||||
(fn (node)
|
||||
(if (list? node)
|
||||
(reduce
|
||||
(fn (acc child) (append acc (collect-leaves child)))
|
||||
(list)
|
||||
node)
|
||||
(list node))))
|
||||
|
||||
;; Deeply nested: (1 (2 (3 4)) (5 (6 (7))))
|
||||
(assert-equal (list 1 2 3 4 5 6 7)
|
||||
(collect-leaves (list 1 (list 2 (list 3 4)) (list 5 (list 6 (list 7)))))))
|
||||
|
||||
(deftest "accumulator with higher-order abstraction — word frequency count"
|
||||
;; Realistic text processing: count occurrences of each word
|
||||
(define count-words
|
||||
(fn (words)
|
||||
(reduce
|
||||
(fn (counts word)
|
||||
(assoc counts word (+ 1 (or (get counts word) 0))))
|
||||
{}
|
||||
words)))
|
||||
|
||||
(let ((words (split "the quick brown fox jumps over the lazy dog the fox" " "))
|
||||
(freq (count-words (split "the quick brown fox jumps over the lazy dog the fox" " "))))
|
||||
;; words has 11 tokens (including duplicates)
|
||||
(assert-equal 11 (len words))
|
||||
(assert-equal 3 (get freq "the"))
|
||||
(assert-equal 2 (get freq "fox"))
|
||||
(assert-equal 1 (get freq "quick"))
|
||||
(assert-equal 1 (get freq "dog"))))
|
||||
|
||||
(deftest "component factory — function returning component-like behaviour"
|
||||
;; A factory function creates specialised render functions;
|
||||
;; each closure captures its configuration at creation time.
|
||||
(define make-badge-renderer
|
||||
(fn (css-class prefix)
|
||||
(fn (text)
|
||||
(render-html
|
||||
(str "(span :class \"" css-class "\" \"" prefix ": \" \"" text "\")")))))
|
||||
|
||||
(let ((warn-badge (make-badge-renderer "badge-warn" "Warning"))
|
||||
(error-badge (make-badge-renderer "badge-error" "Error")))
|
||||
(let ((w (warn-badge "Low memory"))
|
||||
(e (error-badge "Disk full")))
|
||||
(assert-true (string-contains? w "badge-warn"))
|
||||
(assert-true (string-contains? w "Warning"))
|
||||
(assert-true (string-contains? w "Low memory"))
|
||||
(assert-true (string-contains? e "badge-error"))
|
||||
(assert-true (string-contains? e "Error"))
|
||||
(assert-true (string-contains? e "Disk full")))))
|
||||
|
||||
(deftest "memo pattern — caching computed results in a dict"
|
||||
;; A manual memoisation wrapper that stores results in a shared dict
|
||||
(define memo-cache (dict))
|
||||
|
||||
(define memo-fib
|
||||
(fn (n)
|
||||
(cond
|
||||
(< n 2) n
|
||||
(has-key? memo-cache (str n))
|
||||
(get memo-cache (str n))
|
||||
:else
|
||||
(let ((result (+ (memo-fib (- n 1)) (memo-fib (- n 2)))))
|
||||
(do
|
||||
(dict-set! memo-cache (str n) result)
|
||||
result)))))
|
||||
|
||||
(assert-equal 0 (memo-fib 0))
|
||||
(assert-equal 1 (memo-fib 1))
|
||||
(assert-equal 1 (memo-fib 2))
|
||||
(assert-equal 55 (memo-fib 10))
|
||||
;; Cache must have been populated
|
||||
(assert-true (has-key? memo-cache "10"))
|
||||
(assert-equal 55 (get memo-cache "10"))))
|
||||
306
spec/tests/test-render-advanced.sx
Normal file
306
spec/tests/test-render-advanced.sx
Normal file
@@ -0,0 +1,306 @@
|
||||
;; ==========================================================================
|
||||
;; test-render-advanced.sx — Advanced HTML rendering tests
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: render.sx, adapter-html.sx, eval.sx
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; render-html (sx-source) -> HTML string
|
||||
;; Parses the sx-source string, evaluates via render-to-html in a
|
||||
;; fresh env, and returns the resulting HTML string.
|
||||
;;
|
||||
;; Covers advanced rendering scenarios not addressed in test-render.sx:
|
||||
;; - Deeply nested component calls
|
||||
;; - Dynamic content (let, define, cond, case)
|
||||
;; - List processing patterns (map, filter, reduce, map-indexed)
|
||||
;; - Component patterns (defaults, nil bodies, map over children)
|
||||
;; - Special element edge cases (fragments, void attrs, nil content)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Nested component rendering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-nested-components"
|
||||
(deftest "component calling another component"
|
||||
;; Inner component renders a span; outer wraps it in a div
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~inner (&key label) (span label))
|
||||
(defcomp ~outer (&key text) (div (~inner :label text)))
|
||||
(~outer :text \"hello\"))")))
|
||||
(assert-true (string-contains? html "<div>"))
|
||||
(assert-true (string-contains? html "<span>hello</span>"))
|
||||
(assert-true (string-contains? html "</div>"))))
|
||||
|
||||
(deftest "three levels of nesting"
|
||||
;; A → B → C, each wrapping the next
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~c () (em \"deep\"))
|
||||
(defcomp ~b () (strong (~c)))
|
||||
(defcomp ~a () (p (~b)))
|
||||
(~a))")))
|
||||
(assert-true (string-contains? html "<p>"))
|
||||
(assert-true (string-contains? html "<strong>"))
|
||||
(assert-true (string-contains? html "<em>deep</em>"))
|
||||
(assert-true (string-contains? html "</strong>"))
|
||||
(assert-true (string-contains? html "</p>"))))
|
||||
|
||||
(deftest "component with children that are components"
|
||||
;; ~badge renders as a span; ~toolbar wraps whatever children it gets
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~badge (&key text) (span :class \"badge\" text))
|
||||
(defcomp ~toolbar (&rest children) (nav children))
|
||||
(~toolbar (~badge :text \"Home\") (~badge :text \"About\")))")))
|
||||
(assert-true (string-contains? html "<nav>"))
|
||||
(assert-true (string-contains? html "class=\"badge\""))
|
||||
(assert-true (string-contains? html "Home"))
|
||||
(assert-true (string-contains? html "About"))
|
||||
(assert-true (string-contains? html "</nav>"))))
|
||||
|
||||
(deftest "component that wraps children in a div"
|
||||
;; Classic container pattern: keyword title + arbitrary children
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~card (&key title &rest children)
|
||||
(div :class \"card\"
|
||||
(h3 title)
|
||||
children))
|
||||
(~card :title \"My Card\"
|
||||
(p \"First\")
|
||||
(p \"Second\")))")))
|
||||
(assert-true (string-contains? html "class=\"card\""))
|
||||
(assert-true (string-contains? html "<h3>My Card</h3>"))
|
||||
(assert-true (string-contains? html "<p>First</p>"))
|
||||
(assert-true (string-contains? html "<p>Second</p>")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Dynamic content
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-dynamic-content"
|
||||
(deftest "let binding computed values"
|
||||
;; let computes a value and uses it in the rendered output
|
||||
(assert-equal "<span>30</span>"
|
||||
(render-html "(let ((x 10) (y 20)) (span (+ x y)))")))
|
||||
|
||||
(deftest "define inside do block"
|
||||
;; Definitions accumulate across do statements
|
||||
(assert-equal "<p>hello world</p>"
|
||||
(render-html "(do
|
||||
(define greeting \"hello\")
|
||||
(define target \"world\")
|
||||
(p (str greeting \" \" target)))")))
|
||||
|
||||
(deftest "nested let scoping"
|
||||
;; Inner let shadows outer binding; outer binding restored after
|
||||
(assert-equal "<div><span>inner</span><span>outer</span></div>"
|
||||
(render-html "(do
|
||||
(define label \"outer\")
|
||||
(div
|
||||
(let ((label \"inner\")) (span label))
|
||||
(span label)))")))
|
||||
|
||||
(deftest "cond dispatching different elements"
|
||||
;; Different cond branches produce different tags
|
||||
(assert-equal "<h1>big</h1>"
|
||||
(render-html "(let ((size \"large\"))
|
||||
(cond (= size \"large\") (h1 \"big\")
|
||||
(= size \"small\") (h6 \"small\")
|
||||
:else (p \"medium\")))"))
|
||||
(assert-equal "<h6>small</h6>"
|
||||
(render-html "(let ((size \"small\"))
|
||||
(cond (= size \"large\") (h1 \"big\")
|
||||
(= size \"small\") (h6 \"small\")
|
||||
:else (p \"medium\")))"))
|
||||
(assert-equal "<p>medium</p>"
|
||||
(render-html "(let ((size \"other\"))
|
||||
(cond (= size \"large\") (h1 \"big\")
|
||||
(= size \"small\") (h6 \"small\")
|
||||
:else (p \"medium\")))")))
|
||||
|
||||
(deftest "cond dispatching different elements"
|
||||
;; cond on a value selects between different rendered elements
|
||||
(assert-equal "<strong>bold</strong>"
|
||||
(render-html "(let ((style \"bold\"))
|
||||
(cond (= style \"bold\") (strong \"bold\")
|
||||
(= style \"italic\") (em \"italic\")
|
||||
:else (span \"normal\")))"))
|
||||
(assert-equal "<em>italic</em>"
|
||||
(render-html "(let ((style \"italic\"))
|
||||
(cond (= style \"bold\") (strong \"bold\")
|
||||
(= style \"italic\") (em \"italic\")
|
||||
:else (span \"normal\")))"))
|
||||
(assert-equal "<span>normal</span>"
|
||||
(render-html "(let ((style \"other\"))
|
||||
(cond (= style \"bold\") (strong \"bold\")
|
||||
(= style \"italic\") (em \"italic\")
|
||||
:else (span \"normal\")))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; List processing patterns
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-list-patterns"
|
||||
(deftest "map producing li items inside ul"
|
||||
(assert-equal "<ul><li>a</li><li>b</li><li>c</li></ul>"
|
||||
(render-html "(ul (map (fn (x) (li x)) (list \"a\" \"b\" \"c\")))")))
|
||||
|
||||
(deftest "filter then map inside container"
|
||||
;; Keep only even numbers, render each as a span
|
||||
(assert-equal "<div><span>2</span><span>4</span></div>"
|
||||
(render-html "(div (map (fn (x) (span x))
|
||||
(filter (fn (x) (= (mod x 2) 0))
|
||||
(list 1 2 3 4 5))))")))
|
||||
|
||||
(deftest "reduce building a string inside a span"
|
||||
;; Join words with a separator via reduce, wrap in span
|
||||
(assert-equal "<span>a-b-c</span>"
|
||||
(render-html "(let ((words (list \"a\" \"b\" \"c\")))
|
||||
(span (reduce (fn (acc w)
|
||||
(if (= acc \"\")
|
||||
w
|
||||
(str acc \"-\" w)))
|
||||
\"\"
|
||||
words)))")))
|
||||
|
||||
(deftest "map-indexed producing numbered items"
|
||||
;; map-indexed provides both the index and the value
|
||||
(assert-equal "<ol><li>1. alpha</li><li>2. beta</li><li>3. gamma</li></ol>"
|
||||
(render-html "(ol (map-indexed
|
||||
(fn (i x) (li (str (+ i 1) \". \" x)))
|
||||
(list \"alpha\" \"beta\" \"gamma\")))")))
|
||||
|
||||
(deftest "nested map (map inside map)"
|
||||
;; Each outer item produces a ul; inner items produce li
|
||||
(let ((html (render-html
|
||||
"(div (map (fn (row)
|
||||
(ul (map (fn (cell) (li cell)) row)))
|
||||
(list (list \"a\" \"b\")
|
||||
(list \"c\" \"d\"))))")))
|
||||
(assert-true (string-contains? html "<div>"))
|
||||
;; Both inner uls must appear
|
||||
(assert-true (string-contains? html "<li>a</li>"))
|
||||
(assert-true (string-contains? html "<li>b</li>"))
|
||||
(assert-true (string-contains? html "<li>c</li>"))
|
||||
(assert-true (string-contains? html "<li>d</li>"))))
|
||||
|
||||
(deftest "empty map produces no children"
|
||||
;; mapping over an empty list contributes nothing to the parent
|
||||
(assert-equal "<ul></ul>"
|
||||
(render-html "(ul (map (fn (x) (li x)) (list)))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Component patterns
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-component-patterns"
|
||||
(deftest "component with conditional rendering (when)"
|
||||
;; when true → renders child; when false → renders nothing
|
||||
(let ((html-on (render-html
|
||||
"(do (defcomp ~toggle (&key active)
|
||||
(div (when active (span \"on\"))))
|
||||
(~toggle :active true))"))
|
||||
(html-off (render-html
|
||||
"(do (defcomp ~toggle (&key active)
|
||||
(div (when active (span \"on\"))))
|
||||
(~toggle :active false))")))
|
||||
(assert-true (string-contains? html-on "<span>on</span>"))
|
||||
(assert-false (string-contains? html-off "<span>"))))
|
||||
|
||||
(deftest "component with default keyword value (or pattern)"
|
||||
;; Missing keyword falls back to default; explicit value overrides it
|
||||
(let ((with-default (render-html
|
||||
"(do (defcomp ~btn (&key label)
|
||||
(button (or label \"Click me\")))
|
||||
(~btn))"))
|
||||
(with-value (render-html
|
||||
"(do (defcomp ~btn (&key label)
|
||||
(button (or label \"Click me\")))
|
||||
(~btn :label \"Submit\"))")))
|
||||
(assert-equal "<button>Click me</button>" with-default)
|
||||
(assert-equal "<button>Submit</button>" with-value)))
|
||||
|
||||
(deftest "component composing other components"
|
||||
;; ~page uses ~header and ~footer as sub-components
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~header () (header (h1 \"Top\")))
|
||||
(defcomp ~footer () (footer \"Bottom\"))
|
||||
(defcomp ~page () (div (~header) (~footer)))
|
||||
(~page))")))
|
||||
(assert-true (string-contains? html "<header>"))
|
||||
(assert-true (string-contains? html "<h1>Top</h1>"))
|
||||
(assert-true (string-contains? html "<footer>"))
|
||||
(assert-true (string-contains? html "Bottom"))))
|
||||
|
||||
(deftest "component with map over children"
|
||||
;; Component receives a list via keyword, maps it to li elements
|
||||
(let ((html (render-html
|
||||
"(do
|
||||
(defcomp ~item-list (&key items)
|
||||
(ul (map (fn (x) (li x)) items)))
|
||||
(~item-list :items (list \"x\" \"y\" \"z\")))")))
|
||||
(assert-true (string-contains? html "<ul>"))
|
||||
(assert-true (string-contains? html "<li>x</li>"))
|
||||
(assert-true (string-contains? html "<li>y</li>"))
|
||||
(assert-true (string-contains? html "<li>z</li>"))
|
||||
(assert-true (string-contains? html "</ul>"))))
|
||||
|
||||
(deftest "component that renders nothing (nil body)"
|
||||
;; A component whose body evaluates to nil produces no output
|
||||
(assert-equal ""
|
||||
(render-html "(do (defcomp ~empty () nil) (~empty))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Special element edge cases
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "render-special-elements"
|
||||
(deftest "fragment with mixed children: elements and bare text"
|
||||
;; (<> ...) strips the wrapper — children appear side by side
|
||||
(assert-equal "<p>a</p>text<p>b</p>"
|
||||
(render-html "(<> (p \"a\") \"text\" (p \"b\"))")))
|
||||
|
||||
(deftest "void element with multiple attributes"
|
||||
;; input is void (self-closing) and must carry its attrs correctly
|
||||
(let ((html (render-html "(input :type \"text\" :placeholder \"Search…\")")))
|
||||
(assert-true (string-contains? html "<input"))
|
||||
(assert-true (string-contains? html "type=\"text\""))
|
||||
(assert-true (string-contains? html "placeholder="))
|
||||
(assert-true (string-contains? html "/>"))
|
||||
(assert-false (string-contains? html "</input>"))))
|
||||
|
||||
(deftest "boolean attribute true emits name only"
|
||||
;; :disabled true → the word "disabled" appears without a value
|
||||
(let ((html (render-html "(input :type \"checkbox\" :disabled true)")))
|
||||
(assert-true (string-contains? html "disabled"))
|
||||
(assert-false (string-contains? html "disabled=\""))))
|
||||
|
||||
(deftest "boolean attribute false is omitted entirely"
|
||||
;; :disabled false → the attribute must not appear at all
|
||||
(let ((html (render-html "(input :type \"checkbox\" :disabled false)")))
|
||||
(assert-false (string-contains? html "disabled"))))
|
||||
|
||||
(deftest "raw number as element content"
|
||||
;; Numbers passed as children must be coerced to their string form
|
||||
(assert-equal "<span>42</span>"
|
||||
(render-html "(span 42)")))
|
||||
|
||||
(deftest "nil content omitted, non-nil siblings kept"
|
||||
;; nil should not contribute text or tags; sibling content survives
|
||||
(let ((html (render-html "(div nil \"hello\")")))
|
||||
(assert-true (string-contains? html "hello"))
|
||||
(assert-false (string-contains? html "nil"))))
|
||||
|
||||
(deftest "nil-only content leaves element empty"
|
||||
;; A div whose only child is nil should render as an empty div
|
||||
(assert-equal "<div></div>"
|
||||
(render-html "(div nil)"))))
|
||||
296
spec/tests/test-signals-advanced.sx
Normal file
296
spec/tests/test-signals-advanced.sx
Normal file
@@ -0,0 +1,296 @@
|
||||
;; ==========================================================================
|
||||
;; test-signals-advanced.sx — Stress tests for the reactive signal system
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: signals.sx (signal, deref, reset!, swap!, computed,
|
||||
;; effect, batch)
|
||||
;;
|
||||
;; Note: Multi-expression lambda bodies are wrapped in (do ...) for
|
||||
;; compatibility with evaluators that support only single-expression bodies.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Signal basics extended
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "signal-basics-extended"
|
||||
(deftest "signal with nil initial value"
|
||||
(let ((s (signal nil)))
|
||||
(assert-true (signal? s))
|
||||
(assert-nil (deref s))))
|
||||
|
||||
(deftest "signal with list value"
|
||||
(let ((s (signal (list 1 2 3))))
|
||||
(assert-equal (list 1 2 3) (deref s))
|
||||
(reset! s (list 4 5 6))
|
||||
(assert-equal (list 4 5 6) (deref s))))
|
||||
|
||||
(deftest "signal with dict value"
|
||||
(let ((s (signal {:name "alice" :score 42})))
|
||||
(assert-equal "alice" (get (deref s) "name"))
|
||||
(assert-equal 42 (get (deref s) "score"))))
|
||||
|
||||
(deftest "signal with lambda value"
|
||||
(let ((fn-val (fn (x) (* x 2)))
|
||||
(s (signal nil)))
|
||||
(reset! s fn-val)
|
||||
;; The stored lambda should be callable
|
||||
(assert-equal 10 ((deref s) 5))))
|
||||
|
||||
(deftest "multiple signals independent of each other"
|
||||
(let ((a (signal 1))
|
||||
(b (signal 2))
|
||||
(c (signal 3)))
|
||||
(reset! a 10)
|
||||
;; b and c must be unchanged
|
||||
(assert-equal 10 (deref a))
|
||||
(assert-equal 2 (deref b))
|
||||
(assert-equal 3 (deref c))
|
||||
(reset! b 20)
|
||||
(assert-equal 10 (deref a))
|
||||
(assert-equal 20 (deref b))
|
||||
(assert-equal 3 (deref c))))
|
||||
|
||||
(deftest "deref returns current value not a stale snapshot"
|
||||
(let ((s (signal "first")))
|
||||
(let ((snap1 (deref s)))
|
||||
(reset! s "second")
|
||||
(let ((snap2 (deref s)))
|
||||
;; snap1 holds the string "first" (immutable), snap2 is "second"
|
||||
(assert-equal "first" snap1)
|
||||
(assert-equal "second" snap2))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Computed chains
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "computed-chains"
|
||||
(deftest "chain of three computed signals"
|
||||
(let ((base (signal 2))
|
||||
(doubled (computed (fn () (* 2 (deref base)))))
|
||||
(tripled (computed (fn () (* 3 (deref doubled))))))
|
||||
;; Initial: base=2 → doubled=4 → tripled=12
|
||||
(assert-equal 4 (deref doubled))
|
||||
(assert-equal 12 (deref tripled))
|
||||
;; Update propagates through the entire chain
|
||||
(reset! base 5)
|
||||
(assert-equal 10 (deref doubled))
|
||||
(assert-equal 30 (deref tripled))))
|
||||
|
||||
(deftest "computed depending on multiple signals"
|
||||
(let ((x (signal 3))
|
||||
(y (signal 4))
|
||||
(hypo (computed (fn ()
|
||||
;; sqrt(x^2 + y^2) — Pythagorean hypotenuse (integer approx)
|
||||
(+ (* (deref x) (deref x))
|
||||
(* (deref y) (deref y)))))))
|
||||
(assert-equal 25 (deref hypo))
|
||||
(reset! x 0)
|
||||
(assert-equal 16 (deref hypo))
|
||||
(reset! y 0)
|
||||
(assert-equal 0 (deref hypo))))
|
||||
|
||||
(deftest "computed with conditional logic"
|
||||
(let ((flag (signal true))
|
||||
(a (signal 10))
|
||||
(b (signal 99))
|
||||
(result (computed (fn ()
|
||||
(if (deref flag) (deref a) (deref b))))))
|
||||
(assert-equal 10 (deref result))
|
||||
(reset! flag false)
|
||||
(assert-equal 99 (deref result))
|
||||
(reset! b 42)
|
||||
(assert-equal 42 (deref result))
|
||||
(reset! flag true)
|
||||
(assert-equal 10 (deref result))))
|
||||
|
||||
(deftest "diamond dependency: A->B, A->C, B+C->D"
|
||||
;; A change in A must propagate via both B and C to D,
|
||||
;; but D must still hold a coherent (not intermediate) value.
|
||||
(let ((A (signal 1))
|
||||
(B (computed (fn () (* 2 (deref A)))))
|
||||
(C (computed (fn () (* 3 (deref A)))))
|
||||
(D (computed (fn () (+ (deref B) (deref C))))))
|
||||
;; A=1 → B=2, C=3 → D=5
|
||||
(assert-equal 2 (deref B))
|
||||
(assert-equal 3 (deref C))
|
||||
(assert-equal 5 (deref D))
|
||||
;; A=4 → B=8, C=12 → D=20
|
||||
(reset! A 4)
|
||||
(assert-equal 8 (deref B))
|
||||
(assert-equal 12 (deref C))
|
||||
(assert-equal 20 (deref D))))
|
||||
|
||||
(deftest "computed returns nil when source signal is nil"
|
||||
(let ((s (signal nil))
|
||||
(c (computed (fn ()
|
||||
(let ((v (deref s)))
|
||||
(when (not (nil? v)) (* v 2)))))))
|
||||
(assert-nil (deref c))
|
||||
(reset! s 7)
|
||||
(assert-equal 14 (deref c))
|
||||
(reset! s nil)
|
||||
(assert-nil (deref c)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Effect patterns
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "effect-patterns"
|
||||
(deftest "effect runs immediately on creation"
|
||||
(let ((ran (signal false)))
|
||||
(effect (fn () (reset! ran true)))
|
||||
(assert-true (deref ran))))
|
||||
|
||||
(deftest "effect re-runs when dependency changes"
|
||||
(let ((n (signal 0))
|
||||
(calls (signal 0)))
|
||||
(effect (fn () (do (deref n) (swap! calls inc))))
|
||||
;; Initial run counts as 1
|
||||
(assert-equal 1 (deref calls))
|
||||
(reset! n 1)
|
||||
(assert-equal 2 (deref calls))
|
||||
(reset! n 2)
|
||||
(assert-equal 3 (deref calls))))
|
||||
|
||||
(deftest "effect with multiple dependencies"
|
||||
(let ((a (signal "x"))
|
||||
(b (signal "y"))
|
||||
(calls (signal 0)))
|
||||
(effect (fn () (do (deref a) (deref b) (swap! calls inc))))
|
||||
(assert-equal 1 (deref calls))
|
||||
;; Changing a triggers re-run
|
||||
(reset! a "x2")
|
||||
(assert-equal 2 (deref calls))
|
||||
;; Changing b also triggers re-run
|
||||
(reset! b "y2")
|
||||
(assert-equal 3 (deref calls))))
|
||||
|
||||
(deftest "effect cleanup function called on re-run"
|
||||
(let ((trigger (signal 0))
|
||||
(cleanups (signal 0)))
|
||||
(effect (fn () (do
|
||||
(deref trigger)
|
||||
;; Return a cleanup function
|
||||
(fn () (swap! cleanups inc)))))
|
||||
;; First run — no previous cleanup to call
|
||||
(assert-equal 0 (deref cleanups))
|
||||
;; Second run — previous cleanup fires first
|
||||
(reset! trigger 1)
|
||||
(assert-equal 1 (deref cleanups))
|
||||
;; Third run — second cleanup fires
|
||||
(reset! trigger 2)
|
||||
(assert-equal 2 (deref cleanups))))
|
||||
|
||||
(deftest "effect tracks only actually-deref'd signals"
|
||||
;; An effect that conditionally reads signal B should only re-run
|
||||
;; for B changes when B is actually read (flag=true).
|
||||
(let ((flag (signal true))
|
||||
(b (signal 0))
|
||||
(calls (signal 0)))
|
||||
(effect (fn () (do
|
||||
(deref flag)
|
||||
(when (deref flag) (deref b))
|
||||
(swap! calls inc))))
|
||||
;; Initial run reads both flag and b
|
||||
(assert-equal 1 (deref calls))
|
||||
;; flip flag to false — re-run, but now b is NOT deref'd
|
||||
(reset! flag false)
|
||||
(assert-equal 2 (deref calls))
|
||||
;; Changing b should NOT trigger another run (b wasn't deref'd last time)
|
||||
(reset! b 99)
|
||||
(assert-equal 2 (deref calls)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Batch behavior
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "batch-behavior"
|
||||
(deftest "batch coalesces multiple signal updates into one effect run"
|
||||
(let ((a (signal 0))
|
||||
(b (signal 0))
|
||||
(run-count (signal 0)))
|
||||
(effect (fn () (do (deref a) (deref b) (swap! run-count inc))))
|
||||
;; Initial run
|
||||
(assert-equal 1 (deref run-count))
|
||||
;; Two writes inside a single batch → one effect run, not two
|
||||
(batch (fn () (do
|
||||
(reset! a 1)
|
||||
(reset! b 2))))
|
||||
(assert-equal 2 (deref run-count))))
|
||||
|
||||
(deftest "nested batch — inner batch does not flush, outer batch does"
|
||||
(let ((s (signal 0))
|
||||
(run-count (signal 0)))
|
||||
(effect (fn () (do (deref s) (swap! run-count inc))))
|
||||
(assert-equal 1 (deref run-count))
|
||||
(batch (fn ()
|
||||
(batch (fn ()
|
||||
(reset! s 1)))
|
||||
;; Still inside outer batch — should not have fired yet
|
||||
(reset! s 2)))
|
||||
;; Outer batch ends → exactly one more run
|
||||
(assert-equal 2 (deref run-count))
|
||||
;; Final value is the last write
|
||||
(assert-equal 2 (deref s))))
|
||||
|
||||
(deftest "batch with computed — computed updates once not per signal write"
|
||||
(let ((x (signal 0))
|
||||
(y (signal 0))
|
||||
(sum (computed (fn () (+ (deref x) (deref y)))))
|
||||
(recomps (signal 0)))
|
||||
;; Track recomputations by wrapping via an effect
|
||||
(effect (fn () (do (deref sum) (swap! recomps inc))))
|
||||
;; Initial: effect + computed both ran once
|
||||
(assert-equal 1 (deref recomps))
|
||||
(batch (fn () (do
|
||||
(reset! x 10)
|
||||
(reset! y 20))))
|
||||
;; sum must reflect both changes
|
||||
(assert-equal 30 (deref sum))
|
||||
;; effect re-ran at most once more (not twice)
|
||||
(assert-equal 2 (deref recomps))))
|
||||
|
||||
(deftest "batch executes the thunk"
|
||||
;; batch runs the thunk for side effects; return value is implementation-defined
|
||||
(let ((s (signal 0)))
|
||||
(batch (fn () (reset! s 42)))
|
||||
(assert-equal 42 (deref s)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Swap patterns
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "swap-patterns"
|
||||
(deftest "swap! with increment function"
|
||||
(let ((n (signal 0)))
|
||||
(swap! n inc)
|
||||
(assert-equal 1 (deref n))
|
||||
(swap! n inc)
|
||||
(assert-equal 2 (deref n))))
|
||||
|
||||
(deftest "swap! with list append"
|
||||
(let ((items (signal (list))))
|
||||
(swap! items (fn (l) (append l "a")))
|
||||
(swap! items (fn (l) (append l "b")))
|
||||
(swap! items (fn (l) (append l "c")))
|
||||
(assert-equal (list "a" "b" "c") (deref items))))
|
||||
|
||||
(deftest "swap! with dict assoc"
|
||||
(let ((store (signal {})))
|
||||
(swap! store (fn (d) (assoc d "x" 1)))
|
||||
(swap! store (fn (d) (assoc d "y" 2)))
|
||||
(assert-equal 1 (get (deref store) "x"))
|
||||
(assert-equal 2 (get (deref store) "y"))))
|
||||
|
||||
(deftest "multiple swap! in sequence build up correct value"
|
||||
(let ((acc (signal 0)))
|
||||
(swap! acc + 10)
|
||||
(swap! acc + 5)
|
||||
(swap! acc - 3)
|
||||
(assert-equal 12 (deref acc)))))
|
||||
@@ -541,6 +541,9 @@
|
||||
"sx-forge" '(~plans/sx-forge/plan-sx-forge-content)
|
||||
"sx-swarm" '(~plans/sx-swarm/plan-sx-swarm-content)
|
||||
"sx-proxy" '(~plans/sx-proxy/plan-sx-proxy-content)
|
||||
"mother-language" '(~plans/mother-language/plan-mother-language-content)
|
||||
"isolated-evaluator" '(~plans/isolated-evaluator/plan-isolated-evaluator-content)
|
||||
"rust-wasm-host" '(~plans/rust-wasm-host/plan-rust-wasm-host-content)
|
||||
"async-eval-convergence" '(~plans/async-eval-convergence/plan-async-eval-convergence-content)
|
||||
"wasm-bytecode-vm" '(~plans/wasm-bytecode-vm/plan-wasm-bytecode-vm-content)
|
||||
"generative-sx" '(~plans/generative-sx/plan-generative-sx-content)
|
||||
|
||||
@@ -14,11 +14,20 @@ def setup_sx_pages() -> None:
|
||||
def _load_sx_page_files() -> None:
|
||||
"""Load defpage definitions from sx/sxc/pages/*.sx."""
|
||||
import os
|
||||
from shared.sx.pages import load_page_dir
|
||||
from shared.sx.pages import load_page_dir, get_page_helpers
|
||||
from shared.sx.jinja_bridge import load_sx_dir, watch_sx_dir, load_service_components
|
||||
_sxc_dir = os.path.dirname(os.path.dirname(__file__)) # sx/sxc/
|
||||
service_root = os.path.dirname(_sxc_dir) # sx/
|
||||
load_service_components(service_root, service_name="sx")
|
||||
load_sx_dir(_sxc_dir)
|
||||
watch_sx_dir(_sxc_dir)
|
||||
# Register page helpers as primitives so the CEK machine can find them
|
||||
# during nested async component expansion (e.g. highlight inside ~docs/code
|
||||
# inside a plan component inside ~layouts/doc). Without this, the env_merge
|
||||
# chain loses page helpers because component closures don't capture them.
|
||||
from shared.sx.ref.sx_ref import PRIMITIVES
|
||||
helpers = get_page_helpers("sx")
|
||||
for name, fn in helpers.items():
|
||||
PRIMITIVES[name] = fn
|
||||
import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5])
|
||||
load_page_dir(os.path.dirname(__file__), "sx")
|
||||
|
||||
@@ -569,6 +569,9 @@
|
||||
"sx-forge" (~plans/sx-forge/plan-sx-forge-content)
|
||||
"sx-swarm" (~plans/sx-swarm/plan-sx-swarm-content)
|
||||
"sx-proxy" (~plans/sx-proxy/plan-sx-proxy-content)
|
||||
"mother-language" (~plans/mother-language/plan-mother-language-content)
|
||||
"isolated-evaluator" (~plans/isolated-evaluator/plan-isolated-evaluator-content)
|
||||
"rust-wasm-host" (~plans/rust-wasm-host/plan-rust-wasm-host-content)
|
||||
"async-eval-convergence" (~plans/async-eval-convergence/plan-async-eval-convergence-content)
|
||||
"wasm-bytecode-vm" (~plans/wasm-bytecode-vm/plan-wasm-bytecode-vm-content)
|
||||
"generative-sx" (~plans/generative-sx/plan-generative-sx-content)
|
||||
@@ -580,9 +583,6 @@
|
||||
"foundations" (~plans/foundations/plan-foundations-content)
|
||||
"cek-reactive" (~plans/cek-reactive/plan-cek-reactive-content)
|
||||
"reactive-runtime" (~plans/reactive-runtime/plan-reactive-runtime-content)
|
||||
"rust-wasm-host" (~plans/rust-wasm-host/plan-rust-wasm-host-content)
|
||||
"isolated-evaluator" (~plans/isolated-evaluator/plan-isolated-evaluator-content)
|
||||
"mother-language" (~plans/mother-language/plan-mother-language-content)
|
||||
:else (~plans/index/plans-index-content))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -344,3 +344,82 @@
|
||||
(deftest "scope pops correctly after body"
|
||||
(assert-equal "outer"
|
||||
(render-sx "(scope \"sc-pop\" :value \"outer\" (scope \"sc-pop\" :value \"inner\" \"ignore\") (context \"sc-pop\"))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Error propagation — errors in aser control flow must throw, not silently
|
||||
;; produce wrong output or fall through to :else branches.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-error-propagation"
|
||||
|
||||
;; --- case: matched branch errors must throw, not fall through to :else ---
|
||||
|
||||
(deftest "case — error in matched branch throws, not falls through"
|
||||
;; If the matched case body references an undefined symbol, the aser must
|
||||
;; throw an error — NOT silently skip to :else.
|
||||
(assert-throws
|
||||
(fn () (render-sx "(case \"x\" \"x\" undefined-symbol-xyz :else \"fallback\")"))))
|
||||
|
||||
(deftest "case — :else body error also throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(case \"no-match\" \"x\" \"ok\" :else undefined-symbol-xyz)"))))
|
||||
|
||||
(deftest "case — matched branch with nested error throws"
|
||||
;; Error inside a tag within the matched body must propagate.
|
||||
(assert-throws
|
||||
(fn () (render-sx "(case \"a\" \"a\" (div (p undefined-sym-abc)) :else (p \"index\"))"))))
|
||||
|
||||
;; --- cond: matched branch errors must throw ---
|
||||
|
||||
(deftest "cond — error in matched branch throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(cond true undefined-cond-sym :else \"fallback\")"))))
|
||||
|
||||
(deftest "cond — error in :else branch throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(cond false \"skip\" :else undefined-cond-sym)"))))
|
||||
|
||||
;; --- if/when: body errors must throw ---
|
||||
|
||||
(deftest "if — error in true branch throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(if true undefined-if-sym \"fallback\")"))))
|
||||
|
||||
(deftest "when — error in body throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(when true undefined-when-sym)"))))
|
||||
|
||||
;; --- let: binding or body errors must throw ---
|
||||
|
||||
(deftest "let — error in binding throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(let ((x undefined-let-sym)) (p x))"))))
|
||||
|
||||
(deftest "let — error in body throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(let ((x 1)) (p undefined-let-body-sym))"))))
|
||||
|
||||
;; --- begin/do: body errors must throw ---
|
||||
|
||||
(deftest "do — error in body throws"
|
||||
(assert-throws
|
||||
(fn () (render-sx "(do \"ok\" undefined-do-sym)"))))
|
||||
|
||||
;; --- component expansion inside case: the production bug ---
|
||||
|
||||
;; --- sync aser serializes components without expansion ---
|
||||
|
||||
(deftest "case — component in matched branch serializes unexpanded"
|
||||
;; Sync aser serializes component calls as SX wire format.
|
||||
;; Expansion only happens in async path with expand-components.
|
||||
(assert-equal "(~broken :title \"test\")"
|
||||
(render-sx
|
||||
"(do (defcomp ~broken (&key title) (div (p title) (p no-such-helper)))
|
||||
(case \"slug\" \"slug\" (~broken :title \"test\") :else \"index\"))")))
|
||||
|
||||
(deftest "case — unmatched falls through to :else correctly"
|
||||
(assert-equal "index"
|
||||
(render-sx
|
||||
"(do (defcomp ~page (&key x) (div x))
|
||||
(case \"miss\" \"hit\" (~page :x \"found\") :else \"index\"))"))))
|
||||
|
||||
Reference in New Issue
Block a user