From a1fa1edf8abae4b4dc769e684766f49aa1b5d498 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 15 Mar 2026 15:32:21 +0000 Subject: [PATCH] Add 68 new tests: continuations-advanced + render-advanced (938/938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hosts/python/tests/run_tests.py | 2 +- shared/static/scripts/sx-browser.js | 2 +- spec/tests/test-continuations-advanced.sx | 368 ++++++++++++++++++++++ spec/tests/test-render-advanced.sx | 306 ++++++++++++++++++ sx/sxc/pages/docs.sx | 6 +- 5 files changed, 679 insertions(+), 5 deletions(-) create mode 100644 spec/tests/test-continuations-advanced.sx create mode 100644 spec/tests/test-render-advanced.sx diff --git a/hosts/python/tests/run_tests.py b/hosts/python/tests/run_tests.py index 146ccd9..7fb28fa 100644 --- a/hosts/python/tests/run_tests.py +++ b/hosts/python/tests/run_tests.py @@ -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: diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 3e9632d..588c99a 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -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); } diff --git a/spec/tests/test-continuations-advanced.sx b/spec/tests/test-continuations-advanced.sx new file mode 100644 index 0000000..8a5dd50 --- /dev/null +++ b/spec/tests/test-continuations-advanced.sx @@ -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)))) + diff --git a/spec/tests/test-render-advanced.sx b/spec/tests/test-render-advanced.sx new file mode 100644 index 0000000..b755cd4 --- /dev/null +++ b/spec/tests/test-render-advanced.sx @@ -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 "
")) + (assert-true (string-contains? html "hello")) + (assert-true (string-contains? html "
")))) + + (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 "

")) + (assert-true (string-contains? html "")) + (assert-true (string-contains? html "deep")) + (assert-true (string-contains? html "")) + (assert-true (string-contains? html "

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

My Card

")) + (assert-true (string-contains? html "

First

")) + (assert-true (string-contains? html "

Second

"))))) + + +;; -------------------------------------------------------------------------- +;; Dynamic content +;; -------------------------------------------------------------------------- + +(defsuite "render-dynamic-content" + (deftest "let binding computed values" + ;; let computes a value and uses it in the rendered output + (assert-equal "30" + (render-html "(let ((x 10) (y 20)) (span (+ x y)))"))) + + (deftest "define inside do block" + ;; Definitions accumulate across do statements + (assert-equal "

hello world

" + (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 "
innerouter
" + (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 "

big

" + (render-html "(let ((size \"large\")) + (cond (= size \"large\") (h1 \"big\") + (= size \"small\") (h6 \"small\") + :else (p \"medium\")))")) + (assert-equal "
small
" + (render-html "(let ((size \"small\")) + (cond (= size \"large\") (h1 \"big\") + (= size \"small\") (h6 \"small\") + :else (p \"medium\")))")) + (assert-equal "

medium

" + (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 "bold" + (render-html "(let ((style \"bold\")) + (cond (= style \"bold\") (strong \"bold\") + (= style \"italic\") (em \"italic\") + :else (span \"normal\")))")) + (assert-equal "italic" + (render-html "(let ((style \"italic\")) + (cond (= style \"bold\") (strong \"bold\") + (= style \"italic\") (em \"italic\") + :else (span \"normal\")))")) + (assert-equal "normal" + (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 "" + (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 "
24
" + (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 "a-b-c" + (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 "
  1. 1. alpha
  2. 2. beta
  3. 3. gamma
" + (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 "
")) + ;; Both inner uls must appear + (assert-true (string-contains? html "
  • a
  • ")) + (assert-true (string-contains? html "
  • b
  • ")) + (assert-true (string-contains? html "
  • c
  • ")) + (assert-true (string-contains? html "
  • d
  • ")))) + + (deftest "empty map produces no children" + ;; mapping over an empty list contributes nothing to the parent + (assert-equal "
      " + (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 "on")) + (assert-false (string-contains? html-off "")))) + + (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 "" with-default) + (assert-equal "" 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 "
      ")) + (assert-true (string-contains? html "

      Top

      ")) + (assert-true (string-contains? html "