From 3119b8e310e8386bee03e04099e3d954ad354481 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 12:37:30 +0000 Subject: [PATCH] Add Testing as top-level docs section with per-module specs New /testing/ section with 6 pages: overview (all specs), evaluator, parser, router, renderer, and runners. Each page runs tests server-side (Python) and offers a browser "Run tests" button (JS). Modular browser runner (sxRunModularTests) loads framework + per-spec sources from DOM. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-test-runner.js | 170 +++++++-- sx/sx/boundary.sx | 5 + sx/sx/layouts.sx | 1 + sx/sx/nav-data.sx | 16 +- sx/sx/testing.sx | 446 +++++++++++++----------- sx/sxc/pages/docs.sx | 74 +++- sx/sxc/pages/helpers.py | 194 +++++++++++ 7 files changed, 674 insertions(+), 232 deletions(-) diff --git a/shared/static/scripts/sx-test-runner.js b/shared/static/scripts/sx-test-runner.js index 89ce30c..2705255 100644 --- a/shared/static/scripts/sx-test-runner.js +++ b/shared/static/scripts/sx-test-runner.js @@ -1,5 +1,5 @@ -// sx-test-runner.js — Run test.sx in the browser using sx-browser.js. -// Loaded on the /specs/testing page. Uses the Sx global. +// sx-test-runner.js — Run SX test specs in the browser using sx-browser.js. +// Supports both legacy (monolithic test.sx) and modular (per-spec) modes. (function() { var NIL = Sx.NIL; function isNil(x) { return x === NIL || x === null || x === undefined; } @@ -21,13 +21,9 @@ return false; } - window.sxRunTests = function(srcId, outId, btnId) { - var src = document.getElementById(srcId).textContent; - var out = document.getElementById(outId); - var btn = document.getElementById(btnId); - + // --- Platform functions shared across all specs --- + function makeEnv() { var stack = [], passed = 0, failed = 0, num = 0, lines = []; - var env = { "try-call": function(thunk) { try { @@ -49,6 +45,7 @@ "push-suite": function(name) { stack.push(name); }, "pop-suite": function() { stack.pop(); }, + // Primitives that sx-browser.js may not expose in env "equal?": function(a, b) { return deepEqual(a, b); }, "eq?": function(a, b) { return a === b; }, "boolean?": function(x) { return typeof x === "boolean"; }, @@ -68,28 +65,159 @@ }, "has-key?": function(d, k) { return d && typeof d === "object" && k in d; }, "append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); }, + "for-each-indexed": function(f, coll) { + for (var i = 0; i < (coll||[]).length; i++) Sx.eval([f, i, coll[i]], env); + }, + "for-each": function(f, coll) { + for (var i = 0; i < (coll||[]).length; i++) Sx.eval([f, coll[i]], env); + }, + "dict-set!": function(d, k, v) { if (d) d[k] = v; }, + "dict-has?": function(d, k) { return d && typeof d === "object" && k in d; }, + "dict-get": function(d, k) { return d ? d[k] : undefined; }, + "starts-with?": function(s, prefix) { return String(s).indexOf(prefix) === 0; }, + "ends-with?": function(s, suffix) { var str = String(s); return str.indexOf(suffix) === str.length - suffix.length; }, + "slice": function(s, start, end) { return end !== undefined ? s.slice(start, end) : s.slice(start); }, + "inc": function(n) { return n + 1; }, + "append!": function(arr, item) { if (Array.isArray(arr)) arr.push(item); }, + "dict": function() { return {}; }, + + // --- Parser platform functions --- + "sx-parse": function(source) { return Sx.parseAll(source); }, + "sx-serialize": function(val) { + if (val === NIL || val === null || val === undefined) return "nil"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "number") return String(val); + if (typeof val === "string") return '"' + val.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + if (val && (val._sym || val._sx_symbol)) return val.name; + if (val && (val._kw || val._sx_keyword)) return ":" + val.name; + if (Array.isArray(val)) return "(" + val.map(function(x) { return env["sx-serialize"](x); }).join(" ") + ")"; + if (val && typeof val === "object") { + var parts = []; + Object.keys(val).forEach(function(k) { parts.push(":" + k); parts.push(env["sx-serialize"](val[k])); }); + return "{" + parts.join(" ") + "}"; + } + return String(val); + }, + "make-symbol": function(name) { return Sx.sym ? Sx.sym(name) : { _sx_symbol: true, name: name, toString: function() { return name; } }; }, + "make-keyword": function(name) { return Sx.kw ? Sx.kw(name) : { _sx_keyword: true, name: name, toString: function() { return name; } }; }, + "symbol-name": function(s) { return s && s.name ? s.name : String(s); }, + "keyword-name": function(k) { return k && k.name ? k.name : String(k); }, + + // --- Render platform function --- + "render-html": function(sxSource) { + if (!Sx.renderToHtml) throw new Error("render-to-html not available"); + var exprs = Sx.parseAll(sxSource); + var result = ""; + for (var i = 0; i < exprs.length; i++) result += Sx.renderToHtml(exprs[i], env); + return result; + }, }; + return { env: env, getResults: function() { return { passed: passed, failed: failed, num: num, lines: lines }; } }; + } + + function evalSource(src, env) { + var exprs = Sx.parseAll(src); + for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env); + } + + function loadRouterFromBootstrap(env) { + if (Sx.splitPathSegments) { + env["split-path-segments"] = Sx.splitPathSegments; + env["parse-route-pattern"] = Sx.parseRoutePattern; + env["match-route-segments"] = Sx.matchRouteSegments; + env["match-route"] = Sx.matchRoute; + env["find-matching-route"] = Sx.findMatchingRoute; + env["make-route-segment"] = Sx.makeRouteSegment; + } + } + + // --- Legacy runner (monolithic test.sx) --- + window.sxRunTests = function(srcId, outId, btnId) { + var src = document.getElementById(srcId).textContent; + var out = document.getElementById(outId); + var btn = document.getElementById(btnId); + var ctx = makeEnv(); try { var t0 = performance.now(); - var exprs = Sx.parseAll(src); - for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env); + evalSource(src, ctx.env); var elapsed = Math.round(performance.now() - t0); - lines.push(""); - lines.push("1.." + num); - lines.push("# tests " + (passed + failed)); - lines.push("# pass " + passed); - if (failed > 0) lines.push("# fail " + failed); - lines.push("# time " + elapsed + "ms"); + var r = ctx.getResults(); + r.lines.push(""); + r.lines.push("1.." + r.num); + r.lines.push("# tests " + (r.passed + r.failed)); + r.lines.push("# pass " + r.passed); + if (r.failed > 0) r.lines.push("# fail " + r.failed); + r.lines.push("# time " + elapsed + "ms"); } catch(e) { - lines.push(""); - lines.push("FATAL: " + (e.message || String(e))); + var r = ctx.getResults(); + r.lines.push(""); + r.lines.push("FATAL: " + (e.message || String(e))); } - out.textContent = lines.join("\n"); + out.textContent = r.lines.join("\n"); out.style.display = "block"; - btn.textContent = passed + "/" + (passed + failed) + " passed" + (failed === 0 ? "" : " (" + failed + " failed)"); - btn.className = failed > 0 + btn.textContent = r.passed + "/" + (r.passed + r.failed) + " passed" + (r.failed === 0 ? "" : " (" + r.failed + " failed)"); + btn.className = r.failed > 0 + ? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default" + : "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default"; + }; + + // --- Modular runner (per-spec or all) --- + var SPECS = { + "eval": { needs: [] }, + "parser": { needs: ["sx-parse"] }, + "router": { needs: [] }, + "render": { needs: ["render-html"] }, + }; + + window.sxRunModularTests = function(specName, outId, btnId) { + var out = document.getElementById(outId); + var btn = document.getElementById(btnId); + var ctx = makeEnv(); + var specs = specName === "all" ? Object.keys(SPECS) : [specName]; + + try { + var t0 = performance.now(); + + // Load framework + var fwEl = document.getElementById("test-framework-source"); + if (fwEl) { + evalSource(fwEl.textContent, ctx.env); + } + + for (var si = 0; si < specs.length; si++) { + var sn = specs[si]; + if (!SPECS[sn]) continue; + + // Load router from bootstrap if needed + if (sn === "router") loadRouterFromBootstrap(ctx.env); + + // Find spec source — either per-spec textarea or embedded in overview + var specEl = document.getElementById("test-spec-" + sn); + if (specEl) { + evalSource(specEl.textContent, ctx.env); + } + } + + var elapsed = Math.round(performance.now() - t0); + var r = ctx.getResults(); + r.lines.push(""); + r.lines.push("1.." + r.num); + r.lines.push("# tests " + (r.passed + r.failed)); + r.lines.push("# pass " + r.passed); + if (r.failed > 0) r.lines.push("# fail " + r.failed); + r.lines.push("# time " + elapsed + "ms"); + } catch(e) { + var r = ctx.getResults(); + r.lines.push(""); + r.lines.push("FATAL: " + (e.message || String(e))); + } + + out.textContent = r.lines.join("\n"); + out.style.display = "block"; + btn.textContent = r.passed + "/" + (r.passed + r.failed) + " passed" + (r.failed === 0 ? "" : " (" + r.failed + " failed)"); + btn.className = r.failed > 0 ? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default" : "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default"; }; diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index 87d062b..fb5dd2a 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -64,3 +64,8 @@ :params () :returns "dict" :service "sx") + +(define-page-helper "run-modular-tests" + :params (spec-name) + :returns "dict" + :service "sx") diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 817f450..32f085b 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -14,6 +14,7 @@ (dict :label "Essays" :href "/essays/") (dict :label "Specs" :href "/specs/") (dict :label "Bootstrappers" :href "/bootstrappers/") + (dict :label "Testing" :href "/testing/") (dict :label "Isomorphism" :href "/isomorphism/") (dict :label "Plans" :href "/plans/")))) (<> (map (lambda (item) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 80589b0..631973b 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -105,8 +105,15 @@ (dict :label "Continuations" :href "/specs/continuations") (dict :label "call/cc" :href "/specs/callcc") (dict :label "Deps" :href "/specs/deps") - (dict :label "Router" :href "/specs/router") - (dict :label "Testing" :href "/specs/testing"))) + (dict :label "Router" :href "/specs/router"))) + +(define testing-nav-items (list + (dict :label "Overview" :href "/testing/") + (dict :label "Evaluator" :href "/testing/eval") + (dict :label "Parser" :href "/testing/parser") + (dict :label "Router" :href "/testing/router") + (dict :label "Renderer" :href "/testing/render") + (dict :label "Runners" :href "/testing/runners"))) (define isomorphism-nav-items (list (dict :label "Roadmap" :href "/isomorphism/") @@ -199,10 +206,7 @@ :prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.") (dict :slug "router" :filename "router.sx" :title "Router" :desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search." - :prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.") - (dict :slug "testing" :filename "test.sx" :title "Testing" - :desc "Self-hosting test framework — SX tests SX. Bootstraps to pytest and Node.js TAP." - :prose "The test spec defines a minimal test framework in SX that bootstraps to every host. Tests are written in SX and verify SX semantics — the language tests itself. The framework uses only primitives already in primitives.sx (assert, equal?, type-of, str, list, len) plus assertion helpers defined in SX (assert-equal, assert-true, assert-false, assert-nil, assert-type, assert-length, assert-contains). Two bootstrap compilers read test.sx and emit native test files: bootstrap_test.py produces a pytest module, bootstrap_test_js.py produces a Node.js TAP script. The same 81 tests run on both platforms, verifying cross-host parity."))) + :prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router."))) (define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items))))) diff --git a/sx/sx/testing.sx b/sx/sx/testing.sx index 6c4ab4a..6bb96e6 100644 --- a/sx/sx/testing.sx +++ b/sx/sx/testing.sx @@ -1,246 +1,288 @@ -;; Testing spec page — SX tests SX. +;; Testing section — SX tests SX across every host. +;; Modular test specs: eval, parser, router, render. +;; Each page shows spec source, runs server-side, offers browser-side run button. -(defcomp ~spec-testing-content (&key spec-source server-results) +;; --------------------------------------------------------------------------- +;; Overview page +;; --------------------------------------------------------------------------- + +(defcomp ~testing-overview-content (&key server-results framework-source eval-source parser-source router-source render-source) (~doc-page :title "Testing" (div :class "space-y-8" ;; Intro (div :class "space-y-4" (p :class "text-lg text-stone-600" - "SX tests itself. " - (code :class "text-violet-700 text-sm" "test.sx") - " is a self-executing test spec — it defines " + "SX tests itself. Test specs are written in SX and executed by SX evaluators on every host. " + "The same assertions run in Python, Node.js, and in the browser — from the same source files.") + (p :class "text-stone-600" + "The framework defines two macros (" (code :class "text-violet-700 text-sm" "deftest") " and " (code :class "text-violet-700 text-sm" "defsuite") - " as macros, writes 81 test cases, and runs them. Any host that provides five platform functions can evaluate the file directly.") - (p :class "text-stone-600" - "This is not a test " - (em "of") " SX — it is a test " (em "in") " SX. The same s-expressions that define how " - (code :class "text-violet-700 text-sm" "if") - " works are used to verify that " - (code :class "text-violet-700 text-sm" "if") - " works. No code generation, no intermediate files — the evaluator runs the spec.")) + ") and nine assertion helpers, all in SX. Each host provides only " + (strong "five platform functions") + " — everything else is pure SX.")) - ;; Server-side results (ran when this page was rendered) - (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "Server: Python evaluator") - (p :class "text-stone-600" - "The Python evaluator ran " - (code :class "text-violet-700 text-sm" "test.sx") - " when this page loaded — " - (strong (str (get server-results "passed") "/" (get server-results "total") " passed")) - " in " (str (get server-results "elapsed-ms")) "ms.") - (pre :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" - (get server-results "output"))) - - ;; Client-side test runner - (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "Browser: JavaScript evaluator") - (p :class "text-stone-600" - "This page loaded " - (code :class "text-violet-700 text-sm" "sx-browser.js") - " to render itself. The same evaluator can run " - (code :class "text-violet-700 text-sm" "test.sx") - " right here:") - (div :class "flex items-center gap-4" - (button :id "test-btn" - :class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer" - :onclick "sxRunTests('test-sx-source','test-output','test-btn')" - "Run 81 tests")) - (pre :id "test-output" - :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" - :style "display:none" - "") - ;; Hidden: raw test.sx source for the browser runner - (textarea :id "test-sx-source" :style "display:none" spec-source) - ;; Load the test runner script - (script :src (asset-url "/scripts/sx-test-runner.js"))) - - ;; How it works + ;; Architecture (div :class "space-y-3" (h2 :class "text-2xl font-semibold text-stone-800" "Architecture") - (p :class "text-stone-600" - "The test framework needs five platform functions. Everything else — macros, assertion helpers, test suites — is pure SX:") (div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700" -"test.sx Self-executing: macros + helpers + 81 tests +"test-framework.sx Macros + assertion helpers (loaded first) | - |--- browser sx-browser.js evaluates test.sx in this page - | - |--- run.js Injects 5 platform fns, evaluates test.sx - | | - | +-> sx-browser.js JS evaluator (bootstrapped from spec) - | - |--- run.py Injects 5 platform fns, evaluates test.sx - | - +-> evaluator.py Python evaluator + +-- test-eval.sx 81 tests: evaluator + primitives + +-- test-parser.sx 39 tests: tokenizer, parser, serializer + +-- test-router.sx 18 tests: route matching + param extraction + +-- test-render.sx 23 tests: HTML rendering + components -Platform functions (5 total — everything else is pure SX): +Runners: + run.js Node.js — injects platform fns, runs specs + run.py Python — injects platform fns, runs specs + sx-test-runner.js Browser — runs specs in this page + +Platform functions (5 total): try-call (thunk) -> {:ok true} | {:ok false :error \"msg\"} report-pass (name) -> output pass report-fail (name error) -> output fail push-suite (name) -> push suite context - pop-suite () -> pop suite context"))) + pop-suite () -> pop suite context - ;; Framework - (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "The test framework") - (p :class "text-stone-600" - "The framework defines two macros and nine assertion helpers, all in SX. The macros are the key — they make " - (code :class "text-violet-700 text-sm" "defsuite") - " and " - (code :class "text-violet-700 text-sm" "deftest") - " executable forms, not just declarations:") - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Macros") - (~doc-code :code - (highlight "(defmacro deftest (name &rest body)\n `(let ((result (try-call (fn () ,@body))))\n (if (get result \"ok\")\n (report-pass ,name)\n (report-fail ,name (get result \"error\")))))\n\n(defmacro defsuite (name &rest items)\n `(do (push-suite ,name)\n ,@items\n (pop-suite)))" "lisp"))) - (p :class "text-stone-600 text-sm" - (code :class "text-violet-700 text-sm" "deftest") - " wraps the body in a thunk, passes it to " - (code :class "text-violet-700 text-sm" "try-call") - " (the one platform function that catches errors), then reports pass or fail. " - (code :class "text-violet-700 text-sm" "defsuite") - " pushes a name onto the context stack, runs its children, and pops.") - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Assertion helpers") - (~doc-code :code - (highlight "(define assert-equal\n (fn (expected actual)\n (assert (equal? expected actual)\n (str \"Expected \" (str expected) \" but got \" (str actual)))))\n\n(define assert-true (fn (val) (assert val ...)))\n(define assert-false (fn (val) (assert (not val) ...)))\n(define assert-nil (fn (val) (assert (nil? val) ...)))\n(define assert-type (fn (expected-type val) ...))\n(define assert-length (fn (expected-len col) ...))\n(define assert-contains (fn (item col) ...))\n(define assert-throws (fn (thunk) ...))" "lisp")))) +Per-spec platform functions: + parser: sx-parse, sx-serialize, make-symbol, make-keyword, ... + router: (none — pure spec, uses bootstrapped functions) + render: render-html (wraps parse + render-to-html)"))) - ;; Example tests - (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "Example: SX testing SX") - (p :class "text-stone-600" - "The test suites cover every language feature. Here is the arithmetic suite testing the evaluator's arithmetic primitives:") - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "From test.sx") - (~doc-code :code - (highlight "(defsuite \"arithmetic\"\n (deftest \"addition\"\n (assert-equal 3 (+ 1 2))\n (assert-equal 0 (+ 0 0))\n (assert-equal -1 (+ 1 -2))\n (assert-equal 10 (+ 1 2 3 4)))\n\n (deftest \"subtraction\"\n (assert-equal 1 (- 3 2))\n (assert-equal -1 (- 2 3)))\n\n (deftest \"multiplication\"\n (assert-equal 6 (* 2 3))\n (assert-equal 0 (* 0 100))\n (assert-equal 24 (* 1 2 3 4)))\n\n (deftest \"division\"\n (assert-equal 2 (/ 6 3))\n (assert-equal 2.5 (/ 5 2)))\n\n (deftest \"modulo\"\n (assert-equal 1 (mod 7 3))\n (assert-equal 0 (mod 6 3))))" "lisp")))) + ;; Server results + (when server-results + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Server: Python evaluator") + (p :class "text-stone-600" + "Ran all test specs when this page loaded — " + (strong (str (get server-results "passed") "/" (get server-results "total") " passed")) + " in " (str (get server-results "elapsed-ms")) "ms.") + (pre :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + (get server-results "output")))) - ;; Running tests — JS + ;; Browser test runner (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "JavaScript: direct evaluation") + (h2 :class "text-2xl font-semibold text-stone-800" "Browser: JavaScript evaluator") (p :class "text-stone-600" + "Run all test specs in the browser using " (code :class "text-violet-700 text-sm" "sx-browser.js") - " evaluates " - (code :class "text-violet-700 text-sm" "test.sx") - " directly. The runner injects platform functions and calls " - (code :class "text-violet-700 text-sm" "Sx.eval") - " on each parsed expression:") - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.js") - (~doc-code :code - (highlight "var Sx = require('./sx-browser.js');\nvar src = fs.readFileSync('test.sx', 'utf8');\n\nvar env = {\n 'try-call': function(thunk) {\n try {\n Sx.eval([thunk], env); // call the SX lambda\n return { ok: true };\n } catch(e) {\n return { ok: false, error: e.message };\n }\n },\n 'report-pass': function(name) { console.log('ok - ' + name); },\n 'report-fail': function(name, err) { console.log('not ok - ' + name); },\n 'push-suite': function(n) { stack.push(n); },\n 'pop-suite': function() { stack.pop(); },\n};\n\nvar exprs = Sx.parseAll(src);\nfor (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);" "javascript"))) - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output") - (~doc-code :code - (highlight "$ node shared/sx/tests/run.js\nTAP version 13\nok 1 - literals > numbers are numbers\nok 2 - literals > strings are strings\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash")))) + ":") + (div :class "flex items-center gap-4" + (button :id "test-btn-all" + :class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer" + :onclick "sxRunModularTests('all','test-output-all','test-btn-all')" + "Run all tests")) + (pre :id "test-output-all" + :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + :style "display:none" + "") + ;; Hidden: all spec sources for the browser runner + (textarea :id "test-framework-source" :style "display:none" framework-source) + (textarea :id "test-spec-eval" :style "display:none" eval-source) + (textarea :id "test-spec-parser" :style "display:none" parser-source) + (textarea :id "test-spec-router" :style "display:none" router-source) + (textarea :id "test-spec-render" :style "display:none" render-source) + (script :src (asset-url "/scripts/sx-test-runner.js"))) - ;; Running tests — Python + ;; Test spec index (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "Python: direct evaluation") - (p :class "text-stone-600" - "Same approach — the Python evaluator runs " - (code :class "text-violet-700 text-sm" "test.sx") - " directly:") - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.py") - (~doc-code :code - (highlight "from shared.sx.parser import parse_all\nfrom shared.sx.evaluator import _eval, _trampoline\n\ndef try_call(thunk):\n try:\n _trampoline(_eval([thunk], {}))\n return {'ok': True}\n except Exception as e:\n return {'ok': False, 'error': str(e)}\n\nenv = {\n 'try-call': try_call,\n 'report-pass': report_pass,\n 'report-fail': report_fail,\n 'push-suite': push_suite,\n 'pop-suite': pop_suite,\n}\n\nfor expr in parse_all(src):\n _trampoline(_eval(expr, env))" "python"))) - (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" - (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output") - (~doc-code :code - (highlight "$ python shared/sx/tests/run.py\nTAP version 13\nok 1 - literals > numbers are numbers\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash")))) + (h2 :class "text-2xl font-semibold text-stone-800" "Test specs") + (div :class "grid grid-cols-1 md:grid-cols-2 gap-4" + (a :href "/testing/eval" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" + (h3 :class "font-semibold text-stone-800" "Evaluator") + (p :class "text-sm text-stone-500" "81 tests — literals, arithmetic, strings, lists, dicts, special forms, lambdas, components, macros") + (p :class "text-xs text-violet-600 mt-1" "test-eval.sx")) + (a :href "/testing/parser" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" + (h3 :class "font-semibold text-stone-800" "Parser") + (p :class "text-sm text-stone-500" "39 tests — tokenization, parsing, escape sequences, quote sugar, serialization, round-trips") + (p :class "text-xs text-violet-600 mt-1" "test-parser.sx")) + (a :href "/testing/router" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" + (h3 :class "font-semibold text-stone-800" "Router") + (p :class "text-sm text-stone-500" "18 tests — path splitting, pattern parsing, segment matching, parameter extraction") + (p :class "text-xs text-violet-600 mt-1" "test-router.sx")) + (a :href "/testing/render" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" + (h3 :class "font-semibold text-stone-800" "Renderer") + (p :class "text-sm text-stone-500" "23 tests — elements, attributes, void elements, fragments, escaping, control flow, components") + (p :class "text-xs text-violet-600 mt-1" "test-render.sx")))) ;; What it proves (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3" (h2 :class "text-lg font-semibold text-blue-900" "What this proves") (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm" - (li "The test spec is " (strong "written in SX") " and " (strong "executed by SX") " — no code generation") - (li "The same 81 tests run on " (strong "Python, Node.js, and in the browser") " from the same file") + (li "Test specs are " (strong "written in SX") " and " (strong "executed by SX") " — no code generation") + (li "The same tests run on " (strong "Python, Node.js, and in the browser") " from the same files") (li "Each host provides only " (strong "5 platform functions") " — everything else is pure SX") - (li "Adding a new host means implementing 5 functions, not rewriting tests") - (li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden")) - (li "The spec is " (strong "executable") " — click the button above to prove it"))) + (li "Modular specs test each part independently — evaluator, parser, router, renderer") + (li "Per-spec platform functions extend the 5-function contract for module-specific capabilities") + (li "Platform divergences are " (strong "exposed by the tests") ", not hidden")))))) - ;; Test suites + +;; --------------------------------------------------------------------------- +;; Per-spec test page (reusable for eval, parser, router, render) +;; --------------------------------------------------------------------------- + +(defcomp ~testing-spec-content (&key spec-name spec-title spec-desc spec-source framework-source server-results) + (~doc-page :title spec-title + (div :class "space-y-8" + + ;; Description + (div :class "space-y-4" + (p :class "text-lg text-stone-600" spec-desc)) + + ;; Server-side results + (when server-results + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Server: Python evaluator") + (p :class "text-stone-600" + "Ran " + (code :class "text-violet-700 text-sm" (str "test-" spec-name ".sx")) + " when this page loaded — " + (strong (str (get server-results "passed") "/" (get server-results "total") " passed")) + " in " (str (get server-results "elapsed-ms")) "ms.") + (pre :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + (get server-results "output")))) + + ;; Browser test runner (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "All 15 test suites") - (div :class "overflow-x-auto rounded border border-stone-200" - (table :class "w-full text-left text-sm" - (thead (tr :class "border-b border-stone-200 bg-stone-100" - (th :class "px-3 py-2 font-medium text-stone-600" "Suite") - (th :class "px-3 py-2 font-medium text-stone-600" "Tests") - (th :class "px-3 py-2 font-medium text-stone-600" "Covers"))) - (tbody - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "literals") - (td :class "px-3 py-2" "6") - (td :class "px-3 py-2 text-stone-700" "number, string, boolean, nil, list, dict type checking")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "arithmetic") - (td :class "px-3 py-2" "5") - (td :class "px-3 py-2 text-stone-700" "+, -, *, /, mod with edge cases")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "comparison") - (td :class "px-3 py-2" "3") - (td :class "px-3 py-2 text-stone-700" "=, equal?, <, >, <=, >=")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "strings") - (td :class "px-3 py-2" "7") - (td :class "px-3 py-2 text-stone-700" "str, string-length, substring, contains?, upcase, trim, split/join")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "lists") - (td :class "px-3 py-2" "10") - (td :class "px-3 py-2 text-stone-700" "first, rest, nth, last, cons, append, reverse, empty?, contains?, flatten")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "dicts") - (td :class "px-3 py-2" "6") - (td :class "px-3 py-2 text-stone-700" "literals, get, assoc, dissoc, keys/vals, has-key?, merge")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "predicates") - (td :class "px-3 py-2" "7") - (td :class "px-3 py-2 text-stone-700" "nil?, number?, string?, list?, dict?, boolean?, not")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "special-forms") - (td :class "px-3 py-2" "10") - (td :class "px-3 py-2 text-stone-700" "if, when, cond, and, or, let, let (Clojure), do/begin, define, set!")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "lambdas") - (td :class "px-3 py-2" "5") - (td :class "px-3 py-2 text-stone-700" "basic, closures, as argument, recursion, higher-order returns")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "higher-order") - (td :class "px-3 py-2" "6") - (td :class "px-3 py-2 text-stone-700" "map, filter, reduce, some, every?, map-indexed")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "components") - (td :class "px-3 py-2" "4") - (td :class "px-3 py-2 text-stone-700" "defcomp, &key params, &rest children, defaults")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "macros") - (td :class "px-3 py-2" "3") - (td :class "px-3 py-2 text-stone-700" "defmacro, quasiquote/unquote, splice-unquote")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "threading") - (td :class "px-3 py-2" "1") - (td :class "px-3 py-2 text-stone-700" "-> thread-first macro")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "truthiness") - (td :class "px-3 py-2" "2") - (td :class "px-3 py-2 text-stone-700" "truthy/falsy values (platform-universal subset)")) - (tr - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "edge-cases") - (td :class "px-3 py-2" "6") - (td :class "px-3 py-2 text-stone-700" "nested scoping, recursive map, keywords, dict eval, nil propagation, empty ops")))))) + (h2 :class "text-2xl font-semibold text-stone-800" "Browser: JavaScript evaluator") + (p :class "text-stone-600" + "Run this spec in the browser:") + (div :class "flex items-center gap-4" + (button :id (str "test-btn-" spec-name) + :class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer" + :onclick (str "sxRunModularTests('" spec-name "','test-output-" spec-name "','test-btn-" spec-name "')") + (str "Run " spec-title))) + (pre :id (str "test-output-" spec-name) + :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + :style "display:none" + "") + ;; Hidden: spec source + framework source for the browser runner + (textarea :id (str "test-spec-" spec-name) :style "display:none" spec-source) + (textarea :id "test-framework-source" :style "display:none" framework-source) + (script :src (asset-url "/scripts/sx-test-runner.js"))) ;; Full source (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "Full specification source") + (h2 :class "text-2xl font-semibold text-stone-800" "Specification source") (p :class "text-xs text-stone-400 italic" - "The s-expression source below is the canonical test specification. " - "Any host that implements the five platform functions can evaluate it directly.") + (str "test-" spec-name ".sx") + " — the canonical test specification for this module.") (div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code (highlight spec-source "sx")))))))) + + +;; --------------------------------------------------------------------------- +;; Runners page +;; --------------------------------------------------------------------------- + +(defcomp ~testing-runners-content () + (~doc-page :title "Test Runners" + (div :class "space-y-8" + + (div :class "space-y-4" + (p :class "text-lg text-stone-600" + "Three runners execute the same test specs on different hosts. Each injects the five platform functions and any per-spec extensions, then evaluates the SX source directly.") + (p :class "text-stone-600" + "All runners produce " + (a :href "https://testanything.org/" :class "text-violet-700 underline" "TAP") + " output and accept module names as arguments.")) + + ;; Node.js runner + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Node.js: run.js") + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Usage") + (~doc-code :code + (highlight "# Run all specs\nnode shared/sx/tests/run.js\n\n# Run specific specs\nnode shared/sx/tests/run.js eval parser\n\n# Legacy mode (monolithic test.sx)\nnode shared/sx/tests/run.js --legacy" "bash"))) + (p :class "text-stone-600 text-sm" + "Uses " + (code :class "text-violet-700 text-sm" "sx-browser.js") + " as the evaluator. Router tests use bootstrapped functions from " + (code :class "text-violet-700 text-sm" "Sx.splitPathSegments") + " etc. Render tests use " + (code :class "text-violet-700 text-sm" "Sx.renderToHtml") + ".")) + + ;; Python runner + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Python: run.py") + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Usage") + (~doc-code :code + (highlight "# Run all specs\npython shared/sx/tests/run.py\n\n# Run specific specs\npython shared/sx/tests/run.py eval parser\n\n# Legacy mode (monolithic test.sx)\npython shared/sx/tests/run.py --legacy" "bash"))) + (p :class "text-stone-600 text-sm" + "Uses the hand-written Python evaluator (" + (code :class "text-violet-700 text-sm" "evaluator.py") + "). Router tests import bootstrapped functions from " + (code :class "text-violet-700 text-sm" "sx_ref.py") + " because the hand-written evaluator's " + (code :class "text-violet-700 text-sm" "set!") + " doesn't propagate across lambda closure copies.")) + + ;; Browser runner + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Browser: sx-test-runner.js") + (p :class "text-stone-600" + "Runs in the browser on each test page. The spec source is embedded in a hidden textarea; the runner parses and evaluates it using the same " + (code :class "text-violet-700 text-sm" "sx-browser.js") + " that renders the page itself.") + (p :class "text-stone-600" + "This is the strongest proof of cross-host parity — the browser evaluator that users depend on is the same one running the tests.")) + + ;; Platform functions table + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Platform functions") + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Function") + (th :class "px-3 py-2 font-medium text-stone-600" "Scope") + (th :class "px-3 py-2 font-medium text-stone-600" "Purpose"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "try-call") + (td :class "px-3 py-2" "All specs") + (td :class "px-3 py-2 text-stone-700" "Call a thunk, catch errors, return result dict")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "report-pass") + (td :class "px-3 py-2" "All specs") + (td :class "px-3 py-2 text-stone-700" "Report a passing test")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "report-fail") + (td :class "px-3 py-2" "All specs") + (td :class "px-3 py-2 text-stone-700" "Report a failing test with error message")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "push-suite") + (td :class "px-3 py-2" "All specs") + (td :class "px-3 py-2 text-stone-700" "Push suite name onto context stack")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "pop-suite") + (td :class "px-3 py-2" "All specs") + (td :class "px-3 py-2 text-stone-700" "Pop suite name from context stack")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "sx-parse") + (td :class "px-3 py-2" "Parser") + (td :class "px-3 py-2 text-stone-700" "Parse SX source to AST")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "sx-serialize") + (td :class "px-3 py-2" "Parser") + (td :class "px-3 py-2 text-stone-700" "Serialize AST back to SX source text")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "render-html") + (td :class "px-3 py-2" "Renderer") + (td :class "px-3 py-2 text-stone-700" "Parse SX source and render to HTML string")))))) + + ;; Adding a new host + (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3" + (h2 :class "text-lg font-semibold text-blue-900" "Adding a new host") + (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm" + (li "Implement the 5 platform functions in your target language") + (li "Load " (code :class "text-sm" "test-framework.sx") " — it defines deftest/defsuite macros") + (li "Load the spec file(s) — the evaluator runs the tests automatically") + (li "Add per-spec platform functions if testing parser or renderer") + (li "Compare TAP output across hosts — divergences reveal platform-specific semantics")))))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 56a9b9b..0913d38 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -343,9 +343,6 @@ :filename (get item "filename") :href (str "/specs/" (get item "slug")) :source (read-spec-file (get item "filename")))) extension-spec-items)) - "testing" (~spec-testing-content - :spec-source (read-spec-file "test.sx") - :server-results (run-spec-tests)) :else (let ((spec (find-spec slug))) (if spec (~spec-detail-content @@ -515,3 +512,74 @@ "glue-decoupling" (~plan-glue-decoupling-content) "social-sharing" (~plan-social-sharing-content) :else (~plans-index-content))) + +;; --------------------------------------------------------------------------- +;; Testing section +;; --------------------------------------------------------------------------- + +(defpage testing-index + :path "/testing/" + :auth :public + :layout (:sx-section + :section "Testing" + :sub-label "Testing" + :sub-href "/testing/" + :sub-nav (~section-nav :items testing-nav-items :current "Overview") + :selected "Overview") + :data (run-modular-tests "all") + :content (~testing-overview-content + :server-results server-results + :framework-source (read-spec-file "test-framework.sx") + :eval-source (read-spec-file "test-eval.sx") + :parser-source (read-spec-file "test-parser.sx") + :router-source (read-spec-file "test-router.sx") + :render-source (read-spec-file "test-render.sx"))) + +(defpage testing-page + :path "/testing/" + :auth :public + :layout (:sx-section + :section "Testing" + :sub-label "Testing" + :sub-href "/testing/" + :sub-nav (~section-nav :items testing-nav-items + :current (find-current testing-nav-items slug)) + :selected (or (find-current testing-nav-items slug) "")) + :data (case slug + "eval" (run-modular-tests "eval") + "parser" (run-modular-tests "parser") + "router" (run-modular-tests "router") + "render" (run-modular-tests "render") + :else (dict)) + :content (case slug + "eval" (~testing-spec-content + :spec-name "eval" + :spec-title "Evaluator Tests" + :spec-desc "81 tests covering the core evaluator and all primitives — literals, arithmetic, comparison, strings, lists, dicts, predicates, special forms, lambdas, higher-order functions, components, macros, threading, and edge cases." + :spec-source (read-spec-file "test-eval.sx") + :framework-source (read-spec-file "test-framework.sx") + :server-results server-results) + "parser" (~testing-spec-content + :spec-name "parser" + :spec-title "Parser Tests" + :spec-desc "39 tests covering tokenization and parsing — integers, floats, strings, escape sequences, booleans, nil, keywords, symbols, lists, dicts, whitespace, comments, quote sugar, serialization, and round-trips." + :spec-source (read-spec-file "test-parser.sx") + :framework-source (read-spec-file "test-framework.sx") + :server-results server-results) + "router" (~testing-spec-content + :spec-name "router" + :spec-title "Router Tests" + :spec-desc "18 tests covering client-side route matching — path splitting, pattern parsing, segment matching, parameter extraction, and route table search." + :spec-source (read-spec-file "test-router.sx") + :framework-source (read-spec-file "test-framework.sx") + :server-results server-results) + "render" (~testing-spec-content + :spec-name "render" + :spec-title "Renderer Tests" + :spec-desc "23 tests covering HTML rendering — elements, attributes, void elements, boolean attributes, fragments, escaping, control flow, and component rendering." + :spec-source (read-spec-file "test-render.sx") + :framework-source (read-spec-file "test-framework.sx") + :server-results server-results) + "runners" (~testing-runners-content) + :else (~testing-overview-content + :server-results server-results))) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 150ca97..ddbc3f8 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -25,6 +25,7 @@ def _register_sx_helpers() -> None: "routing-analyzer-data": _routing_analyzer_data, "data-test-data": _data_test_data, "run-spec-tests": _run_spec_tests, + "run-modular-tests": _run_modular_tests, }) @@ -562,6 +563,199 @@ def _run_spec_tests() -> dict: } +def _run_modular_tests(spec_name: str) -> dict: + """Run modular test specs against the Python SX evaluator. + + spec_name: "eval", "parser", "router", "render", or "all". + Returns dict with server-results key containing results per spec. + """ + import os + import time + from shared.sx.parser import parse_all + from shared.sx.evaluator import _eval, _trampoline + from shared.sx.types import Symbol, Keyword, Lambda, NIL + + ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") + if not os.path.isdir(ref_dir): + ref_dir = "/app/shared/sx/ref" + + suite_stack: list[str] = [] + passed = 0 + failed = 0 + test_num = 0 + lines: list[str] = [] + + def try_call(thunk): + try: + _trampoline(_eval([thunk], env)) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def report_pass(name): + nonlocal passed, test_num + test_num += 1 + passed += 1 + lines.append("ok " + str(test_num) + " - " + " > ".join(suite_stack + [name])) + + def report_fail(name, error): + nonlocal failed, test_num + test_num += 1 + failed += 1 + full = " > ".join(suite_stack + [name]) + lines.append("not ok " + str(test_num) + " - " + full) + lines.append(" # " + str(error)) + + def push_suite(name): + suite_stack.append(name) + + def pop_suite(): + suite_stack.pop() + + def sx_parse(source): + return parse_all(source) + + def sx_serialize(val): + if val is None or val is NIL: + return "nil" + if isinstance(val, bool): + return "true" if val else "false" + if isinstance(val, (int, float)): + return str(val) + if isinstance(val, str): + escaped = val.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if isinstance(val, Symbol): + return val.name + if isinstance(val, Keyword): + return f":{val.name}" + if isinstance(val, list): + inner = " ".join(sx_serialize(x) for x in val) + return f"({inner})" + if isinstance(val, dict): + parts = [] + for k, v in val.items(): + parts.append(f":{k}") + parts.append(sx_serialize(v)) + return "{" + " ".join(parts) + "}" + return str(val) + + def render_html(sx_source): + try: + from shared.sx.ref.sx_ref import render_to_html as _render_to_html + except ImportError: + return "" + exprs = parse_all(sx_source) + render_env = dict(env) + result = "" + for expr in exprs: + result += _render_to_html(expr, render_env) + return result + + def _call_sx(fn, args, caller_env): + if isinstance(fn, Lambda): + from shared.sx.evaluator import _call_lambda + return _trampoline(_call_lambda(fn, list(args), caller_env)) + return fn(*args) + + def _for_each_indexed(fn, coll): + if isinstance(fn, Lambda): + closure = fn.closure + for i, item in enumerate(coll or []): + for p, v in zip(fn.params, [i, item]): + closure[p] = v + _trampoline(_eval(fn.body, closure)) + else: + for i, item in enumerate(coll or []): + fn(i, item) + return NIL + + env = { + "try-call": try_call, + "report-pass": report_pass, + "report-fail": report_fail, + "push-suite": push_suite, + "pop-suite": pop_suite, + "sx-parse": sx_parse, + "sx-serialize": sx_serialize, + "make-symbol": lambda name: Symbol(name), + "make-keyword": lambda name: Keyword(name), + "symbol-name": lambda sym: sym.name if isinstance(sym, Symbol) else str(sym), + "keyword-name": lambda kw: kw.name if isinstance(kw, Keyword) else str(kw), + "render-html": render_html, + "for-each-indexed": _for_each_indexed, + "dict-set!": lambda d, k, v: (d.__setitem__(k, v), NIL)[-1] if isinstance(d, dict) else NIL, + "dict-has?": lambda d, k: isinstance(d, dict) and k in d, + "dict-get": lambda d, k: d.get(k, NIL) if isinstance(d, dict) else NIL, + "append!": lambda lst, item: (lst.append(item), NIL)[-1] if isinstance(lst, list) else NIL, + "inc": lambda n: n + 1, + } + + def eval_file(filename): + filepath = os.path.join(ref_dir, filename) + if not os.path.exists(filepath): + return + with open(filepath) as f: + src = f.read() + exprs = parse_all(src) + for expr in exprs: + _trampoline(_eval(expr, env)) + + SPECS = { + "eval": {"file": "test-eval.sx", "needs": []}, + "parser": {"file": "test-parser.sx", "needs": ["sx-parse"]}, + "router": {"file": "test-router.sx", "needs": []}, + "render": {"file": "test-render.sx", "needs": ["render-html"]}, + } + + specs_to_run = list(SPECS.keys()) if spec_name == "all" else [spec_name] + + t0 = time.monotonic() + + # Load framework + eval_file("test-framework.sx") + + for sn in specs_to_run: + spec = SPECS.get(sn) + if not spec: + continue + + # Load router from bootstrap if needed + if sn == "router": + try: + from shared.sx.ref.sx_ref import ( + split_path_segments, + parse_route_pattern, + match_route_segments, + match_route, + find_matching_route, + make_route_segment, + ) + env["split-path-segments"] = split_path_segments + env["parse-route-pattern"] = parse_route_pattern + env["match-route-segments"] = match_route_segments + env["match-route"] = match_route + env["find-matching-route"] = find_matching_route + env["make-route-segment"] = make_route_segment + except ImportError: + eval_file("router.sx") + + eval_file(spec["file"]) + + elapsed = round((time.monotonic() - t0) * 1000) + + return { + "server-results": { + "passed": passed, + "failed": failed, + "total": passed + failed, + "elapsed-ms": elapsed, + "output": "\n".join(lines), + "spec": spec_name, + } + } + + def _data_test_data() -> dict: """Return test data for the client-side data rendering test page.