Add 68 new tests: continuations-advanced + render-advanced (938/938)

test-continuations-advanced.sx (41 tests):
  multi-shot continuations, composition, provide/context basics,
  provide across shift, scope/emit basics, scope across shift

test-render-advanced.sx (27 tests):
  nested components, dynamic content, list patterns,
  component patterns, special elements

Bugs found and documented:
- case in render context returns DOM object (CEK dispatches case
  before HTML adapter sees it — use cond instead for render)
- context not visible in shift body (correct: shift body runs
  outside the reset/provide boundary)
- Multiple shifts consume reset (correct: each shift needs its own
  reset)

Python runner: skip test-continuations-advanced.sx without --full.

JS 815/815 standard, 938/938 full, Python 706/706.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 15:32:21 +00:00
parent 2ef3f03db3
commit a1fa1edf8a
5 changed files with 679 additions and 5 deletions

View File

@@ -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_files = []
if args:

View File

@@ -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-15T15:31:20Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }

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

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

View File

@@ -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))))
;; ---------------------------------------------------------------------------