From 6e885f49b6530204f5d17ae9a44ed77d507abd31 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 1 Apr 2026 23:46:13 +0000 Subject: [PATCH] Spec explorer: fix SxExpr rendering bugs, add drill-in UX, Playwright tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 3 OCaml bugs that caused spec explorer to hang: - sx_types: inspect outputs quoted string for SxExpr (not bare symbol) - sx_primitives: serialize/to_string extract SxExpr/RawHTML content - sx_render: handle SxExpr in both render-to-html paths Restructure spec explorer for performance: - Lightweight overview: name + kind only (was full source for 141 defs) - Drill-in detail: click definition → params, effects, signature - explore() page function accepts optional second arg for drill-in - spec() passes through non-string slugs from nested routing Fix aser map result wrapping: - aser-special map now wraps results in fragment (<> ...) via aser-fragment - Prevents ((div ...) (div ...)) nested lists that caused client "Not callable" 5 Playwright tests: overview load, no errors, SPA nav, drill-in detail+params Co-Authored-By: Claude Opus 4.6 (1M context) --- hosts/ocaml/lib/sx_primitives.ml | 33 ++- hosts/ocaml/lib/sx_render.ml | 9 +- hosts/ocaml/lib/sx_types.ml | 4 +- sx/sx/handlers/spec-detail.sx | 22 ++ sx/sx/page-functions.sx | 38 ++- sx/sx/spec-introspect.sx | 350 ++++++++++++++----------- sx/sx/specs-explorer.sx | 120 ++++++--- tests/playwright/spec-explorer.spec.js | 74 ++++++ web/adapter-sx.sx | 14 +- 9 files changed, 447 insertions(+), 217 deletions(-) create mode 100644 sx/sx/handlers/spec-detail.sx create mode 100644 tests/playwright/spec-explorer.spec.js diff --git a/hosts/ocaml/lib/sx_primitives.ml b/hosts/ocaml/lib/sx_primitives.ml index bf223e11..24162bb4 100644 --- a/hosts/ocaml/lib/sx_primitives.ml +++ b/hosts/ocaml/lib/sx_primitives.ml @@ -65,6 +65,8 @@ let rec to_string = function | Symbol s -> s | Keyword k -> k | Thunk _ as t -> to_string (!trampoline_hook t) + | SxExpr s -> s + | RawHTML s -> s | v -> inspect v let () = @@ -126,10 +128,23 @@ let () = Number (Float.max lo (Float.min hi x)) | _ -> raise (Eval_error "clamp: 3 args")); register "parse-int" (fun args -> + let parse_leading_int s = + let len = String.length s in + let start = ref 0 in + let neg = len > 0 && s.[0] = '-' in + if neg then start := 1 + else if len > 0 && s.[0] = '+' then start := 1; + let j = ref !start in + while !j < len && s.[!j] >= '0' && s.[!j] <= '9' do incr j done; + if !j > !start then + let n = int_of_string (String.sub s !start (!j - !start)) in + Some (if neg then -n else n) + else None + in match args with - | [String s] -> (match int_of_string_opt s with Some n -> Number (float_of_int n) | None -> Nil) + | [String s] -> (match parse_leading_int s with Some n -> Number (float_of_int n) | None -> Nil) | [String s; default_val] -> - (match int_of_string_opt s with Some n -> Number (float_of_int n) | None -> default_val) + (match parse_leading_int s with Some n -> Number (float_of_int n) | None -> default_val) | [Number n] | [Number n; _] -> Number (float_of_int (int_of_float n)) | [_; default_val] -> default_val | _ -> Nil); @@ -276,7 +291,17 @@ let () = else if String.sub haystack i nl = needle then Number (float_of_int i) else find (i + 1) in find 0 - | _ -> raise (Eval_error "index-of: 2 string args")); + | [List items; target] | [ListRef { contents = items }; target] -> + let eq a b = match a, b with + | String x, String y -> x = y | Number x, Number y -> x = y + | Symbol x, Symbol y -> x = y | Keyword x, Keyword y -> x = y + | Bool x, Bool y -> x = y | Nil, Nil -> true | _ -> a == b in + let rec find i = function + | [] -> Nil + | h :: _ when eq h target -> Number (float_of_int i) + | _ :: tl -> find (i + 1) tl + in find 0 items + | _ -> raise (Eval_error "index-of: 2 string args or list+target")); register "substring" (fun args -> match args with | [String s; Number start; Number end_] -> @@ -655,6 +680,8 @@ let () = match args with [a] -> String (inspect a) | _ -> raise (Eval_error "inspect: 1 arg")); register "serialize" (fun args -> match args with + | [SxExpr s] -> String s + | [RawHTML s] -> String s | [a] -> String (inspect a) (* used for dedup keys in compiler *) | _ -> raise (Eval_error "serialize: 1 arg")); register "make-symbol" (fun args -> diff --git a/hosts/ocaml/lib/sx_render.ml b/hosts/ocaml/lib/sx_render.ml index 59ec5d14..bcdb7662 100644 --- a/hosts/ocaml/lib/sx_render.ml +++ b/hosts/ocaml/lib/sx_render.ml @@ -201,6 +201,7 @@ let rec do_render_to_html (expr : value) (env : env) : string = | String s -> escape_html s | Keyword k -> escape_html k | RawHTML s -> s + | SxExpr s -> s | Symbol s -> let v = Sx_ref.eval_expr (Symbol s) (Env env) in do_render_to_html v env @@ -280,7 +281,12 @@ and render_list_to_html head args env = | _ -> let result = Sx_ref.eval_expr (List (head :: args)) (Env env) in do_render_to_html result env) - with Eval_error _ -> "") + with Eval_error _ -> + (* Primitive or special form — not in env, delegate to CEK *) + (try + let result = Sx_ref.eval_expr (List (head :: args)) (Env env) in + do_render_to_html result env + with Eval_error _ -> "")) | _ -> let result = Sx_ref.eval_expr (List (head :: args)) (Env env) in do_render_to_html result env @@ -456,6 +462,7 @@ let rec render_to_buf buf (expr : value) (env : env) : unit = | String s -> escape_html_buf buf s | Keyword k -> escape_html_buf buf k | RawHTML s -> Buffer.add_string buf s + | SxExpr s -> Buffer.add_string buf s | Symbol s -> let v = Sx_ref.eval_expr (Symbol s) (Env env) in render_to_buf buf v env diff --git a/hosts/ocaml/lib/sx_types.ml b/hosts/ocaml/lib/sx_types.ml index a00fd3bd..7a2ac802 100644 --- a/hosts/ocaml/lib/sx_types.ml +++ b/hosts/ocaml/lib/sx_types.ml @@ -531,9 +531,9 @@ let rec inspect = function | Continuation (_, _) -> "" | NativeFn (name, _) -> Printf.sprintf "" name | Signal _ -> "" - | RawHTML s -> Printf.sprintf "" (String.length s) + | RawHTML s -> Printf.sprintf "\"\"" (String.length s) | Spread _ -> "" - | SxExpr s -> Printf.sprintf "" (String.length s) + | SxExpr s -> Printf.sprintf "\"\"" (String.length s) | Env _ -> "" | CekState _ -> "" | CekFrame f -> Printf.sprintf "" f.cf_type diff --git a/sx/sx/handlers/spec-detail.sx b/sx/sx/handlers/spec-detail.sx new file mode 100644 index 00000000..17387594 --- /dev/null +++ b/sx/sx/handlers/spec-detail.sx @@ -0,0 +1,22 @@ +(defhandler + spec-detail + :path "/sx/api/spec-detail" + :method :get + :returns "element" + (&key) + (let + ((filename (helper "request-arg" "file" "")) + (def-name (helper "request-arg" "name" ""))) + (if + (or (= filename "") (= def-name "")) + (div + :class "text-sm text-stone-400 p-2" + "Missing file or name parameter") + (let + ((d (spec-explore-define filename def-name))) + (if + d + (~specs-explorer/spec-explorer-define-detail :d d :filename filename) + (div + :class "text-sm text-stone-400 p-2" + (str "Definition '" def-name "' not found in " filename))))))) diff --git a/sx/sx/page-functions.sx b/sx/sx/page-functions.sx index f8a1e650..fc45dd8c 100644 --- a/sx/sx/page-functions.sx +++ b/sx/sx/page-functions.sx @@ -213,10 +213,12 @@ spec (fn (slug) - (if + (cond (nil? slug) (quote (~specs/architecture-content)) - (case + (not (= (type-of slug) "string")) + slug + :else (case slug "core" (let @@ -278,22 +280,34 @@ (define explore (fn - (slug) + (slug defname) (if (nil? slug) (quote (~specs/architecture-content)) (let ((found-spec (find-spec slug))) (if - found-spec - (let - ((data (spec-explore (get found-spec "filename") (get found-spec "title") (get found-spec "desc")))) - (if - data - (quasiquote - (~specs-explorer/spec-explorer-content :data (unquote data))) - (quasiquote (~specs/not-found :slug (unquote slug))))) - (quasiquote (~specs/not-found :slug (unquote slug)))))))) + (not found-spec) + (quasiquote (~specs/not-found :slug (unquote slug))) + (if + defname + (let + ((d (spec-explore-define (get found-spec "filename") defname))) + (if + d + (quasiquote + (~specs-explorer/spec-explorer-define-detail + :d (unquote d) + :filename (unquote (get found-spec "filename")))) + (quasiquote + (~specs/not-found :slug (unquote (str slug "." defname)))))) + (let + ((data (spec-explore (get found-spec "filename") (get found-spec "title") (get found-spec "desc")))) + (if + data + (quasiquote + (~specs-explorer/spec-explorer-content :data (unquote data))) + (quasiquote (~specs/not-found :slug (unquote slug))))))))))) (define make-spec-files diff --git a/sx/sx/spec-introspect.sx b/sx/sx/spec-introspect.sx index 51d5fa25..78cd95a2 100644 --- a/sx/sx/spec-introspect.sx +++ b/sx/sx/spec-introspect.sx @@ -1,186 +1,238 @@ -;; --------------------------------------------------------------------------- -;; Spec Introspection — SX macro that reads a spec file and produces -;; structured explorer data. The spec examines itself. -;; --------------------------------------------------------------------------- -;; -;; Usage: (spec-explore "evaluator.sx" "Evaluator" "CEK machine evaluator") -;; -;; Returns a dict with :title, :filename, :desc, :sections, :stats -;; suitable for (~specs-explorer/spec-explorer-content :data result) - -;; Extract the name from a define/defcomp/defmacro/defisland form -(define spec-form-name - (fn (form) - (if (< (len form) 2) nil - (let ((head (symbol-name (first form))) - (name-part (nth form 1))) +(define + spec-form-name + (fn + (form) + (if + (< (len form) 2) + nil + (let + ((head (symbol-name (first form))) (name-part (nth form 1))) (cond (= head "define") - (if (= (type-of name-part) "symbol") - (symbol-name name-part) - nil) + (if (= (type-of name-part) "symbol") (symbol-name name-part) nil) (or (= head "defcomp") (= head "defisland") (= head "defmacro")) - (if (= (type-of name-part) "symbol") - (symbol-name name-part) - nil) + (if (= (type-of name-part) "symbol") (symbol-name name-part) nil) :else nil))))) -;; Extract effects annotation from a define form -;; (define name :effects [mutation io] (fn ...)) -(define spec-form-effects - (fn (form) - (let ((result (list)) - (found false)) - (when (> (len form) 3) +(define + spec-form-effects + (fn + (form) + (let + ((result (list)) (found false)) + (when + (> (len form) 3) (for-each - (fn (item) - (if found - (when (and (list? item) (empty? result)) + (fn + (item) + (if + found + (when + (and (list? item) (empty? result)) (for-each - (fn (eff) - (append! result (if (= (type-of eff) "symbol") - (symbol-name eff) - (str eff)))) + (fn + (eff) + (append! + result + (if + (= (type-of eff) "symbol") + (symbol-name eff) + (str eff)))) item) (set! found false)) - (when (and (= (type-of item) "keyword") - (= (keyword-name item) "effects")) + (when + (and + (= (type-of item) "keyword") + (= (keyword-name item) "effects")) (set! found true)))) (slice form 2))) result))) -;; Extract params from a fn/lambda body within a define -(define spec-form-params - (fn (form) - (if (< (len form) 2) (list) - (let ((body (nth form (- (len form) 1)))) - (if (and (list? body) (> (len body) 1) - (= (type-of (first body)) "symbol") - (or (= (symbol-name (first body)) "fn") - (= (symbol-name (first body)) "lambda"))) - ;; (fn (params...) body) - (let ((raw-params (nth body 1))) - (if (list? raw-params) - (map (fn (p) - (cond - (= (type-of p) "symbol") - {:name (symbol-name p) :type nil} - (and (list? p) (>= (len p) 3) - (= (type-of (first p)) "symbol")) - {:name (symbol-name (first p)) - :type (if (and (>= (len p) 3) - (= (type-of (nth p 2)) "symbol")) - (symbol-name (nth p 2)) - nil)} - :else {:name (str p) :type nil})) +(define + spec-form-params + (fn + (form) + (if + (< (len form) 2) + (list) + (let + ((body (nth form (- (len form) 1)))) + (if + (and + (list? body) + (> (len body) 1) + (= (type-of (first body)) "symbol") + (or + (= (symbol-name (first body)) "fn") + (= (symbol-name (first body)) "lambda"))) + (let + ((raw-params (nth body 1))) + (if + (list? raw-params) + (map + (fn + (p) + (cond + (= (type-of p) "symbol") + {:type nil :name (symbol-name p)} + (and + (list? p) + (>= (len p) 3) + (= (type-of (first p)) "symbol")) + {:type (if (and (>= (len p) 3) (= (type-of (nth p 2)) "symbol")) (symbol-name (nth p 2)) nil) :name (symbol-name (first p))} + :else {:type nil :name (str p)})) raw-params) (list))) (list)))))) -;; Classify a form: "function", "constant", "component", "macro", "island" -(define spec-form-kind - (fn (form) - (let ((head (symbol-name (first form)))) +(define + spec-form-kind + (fn + (form) + (let + ((head (symbol-name (first form)))) (cond - (= head "defcomp") "component" - (= head "defisland") "island" - (= head "defmacro") "macro" + (= head "defcomp") + "component" + (= head "defisland") + "island" + (= head "defmacro") + "macro" (= head "define") - (let ((body (last form))) - (if (and (list? body) (> (len body) 0) - (= (type-of (first body)) "symbol") - (or (= (symbol-name (first body)) "fn") - (= (symbol-name (first body)) "lambda"))) - "function" - "constant")) + (let + ((body (last form))) + (if + (and + (list? body) + (> (len body) 0) + (= (type-of (first body)) "symbol") + (or + (= (symbol-name (first body)) "fn") + (= (symbol-name (first body)) "lambda"))) + "function" + "constant")) :else "unknown")))) -;; Serialize a form back to SX source for display -(define spec-form-source - (fn (form) - (serialize form))) +(define + spec-form-signature + (fn + (form) + (let + ((head (symbol-name (first form))) (name (spec-form-name form))) + (cond + (or + (= head "define") + (= head "defcomp") + (= head "defisland") + (= head "defmacro")) + (let + ((body (last form))) + (if + (and + (list? body) + (> (len body) 0) + (= (type-of (first body)) "symbol") + (or + (= (symbol-name (first body)) "fn") + (= (symbol-name (first body)) "lambda"))) + (let + ((params (nth body 1))) + (str "(" head " " name " (fn " (serialize params) " …))")) + (str "(" head " " name " …)"))) + :else (str "(" head " " name " …)"))))) -;; Group forms into sections based on comment headers -;; Returns list of {:title :comment :defines} -(define spec-group-sections - (fn (forms source) - (let ((sections (list)) - (current-title "Definitions") - (current-comment nil) - (current-defines (list))) - ;; Extract section comments from source - ;; Look for ";; section-name" or ";; --- section ---" patterns +(define + spec-group-sections + (fn + (forms source) + (let + ((sections (list)) + (current-title "Definitions") + (current-comment nil) + (current-defines (list))) (for-each - (fn (form) - (when (and (list? form) (> (len form) 1)) - (let ((name (spec-form-name form))) - (when name - (append! current-defines - {:name name - :kind (spec-form-kind form) - :effects (spec-form-effects form) - :params (spec-form-params form) - :source (spec-form-source form) - :python nil - :javascript nil - :z3 nil - :refs (list) - :tests (list) - :test-count 0}))))) + (fn + (form) + (when + (and (list? form) (> (len form) 1)) + (let + ((name (spec-form-name form))) + (when name (append! current-defines {:kind (spec-form-kind form) :name name}))))) forms) - ;; Flush last section - (when (not (empty? current-defines)) - (append! sections - {:title current-title - :comment current-comment - :defines current-defines})) + (when (not (empty? current-defines)) (append! sections {:defines current-defines :title current-title :comment current-comment})) sections))) -;; Compute stats from sections -(define spec-compute-stats - (fn (sections source) - (let ((total 0) - (pure 0) - (mutation 0) - (io 0) - (render 0) - (lines (len (split source "\n")))) +(define + spec-compute-stats + (fn + (sections source) + (let + ((total 0) + (pure 0) + (mutation 0) + (io 0) + (render 0) + (lines (len (split source "\n")))) (for-each - (fn (section) + (fn + (section) (for-each - (fn (d) + (fn + (d) (set! total (inc total)) - (if (empty? (get d "effects")) + (if + (empty? (get d "effects")) (set! pure (inc pure)) (for-each - (fn (eff) + (fn + (eff) (cond - (= eff "mutation") (set! mutation (inc mutation)) - (= eff "io") (set! io (inc io)) - (= eff "render") (set! render (inc render)))) + (= eff "mutation") + (set! mutation (inc mutation)) + (= eff "io") + (set! io (inc io)) + (= eff "render") + (set! render (inc render)))) (get d "effects")))) (get section "defines"))) sections) - {:total-defines total - :pure-count pure - :mutation-count mutation - :io-count io - :render-count render - :test-total 0 - :lines lines}))) + {:lines lines :io-count io :render-count render :pure-count pure :mutation-count mutation :test-total 0 :total-defines total}))) -;; Main entry point: read, parse, analyze a spec file -(define spec-explore :effects [io] - (fn (filename title desc) - (let ((source (helper "read-spec-file" filename))) - (if (starts-with? source ";; spec file not found") +(define + spec-explore-define + :effects (io) + (fn + (filename def-name) + (let + ((source (helper "read-spec-file" filename))) + (if + (starts-with? source ";; spec file not found") nil - (let ((forms (sx-parse source)) - (sections (spec-group-sections forms source)) - (stats (spec-compute-stats sections source))) - {:filename filename - :title title - :desc desc - :sections sections - :stats stats - :platform-interface (list)}))))) + (let + ((forms (sx-parse source)) (found nil)) + (for-each + (fn + (form) + (when + (and (not found) (list? form) (> (len form) 1)) + (let + ((name (spec-form-name form))) + (when (= name def-name) (set! found {:kind (spec-form-kind form) :effects (spec-form-effects form) :params (spec-form-params form) :source (spec-form-signature form) :name name}))))) + forms) + found))))) + +(define + spec-explore + :effects (io) + (fn + (filename title desc) + (let + ((source (helper "read-spec-file" filename))) + (if + (starts-with? source ";; spec file not found") + nil + (let + ((forms (sx-parse source)) + (sections (spec-group-sections forms source)) + (stats (spec-compute-stats sections source))) + {:stats stats :desc desc :title title :filename filename :platform-interface (list) :sections sections}))))) diff --git a/sx/sx/specs-explorer.sx b/sx/sx/specs-explorer.sx index cbc1fb9d..c08498ab 100644 --- a/sx/sx/specs-explorer.sx +++ b/sx/sx/specs-explorer.sx @@ -11,7 +11,11 @@ :slug (replace (get data "filename") ".sx" "")) (~specs-explorer/spec-explorer-stats :stats (get data "stats")) (map - (fn (section) (~specs-explorer/spec-explorer-section :section section)) + (fn + (section) + (~specs-explorer/spec-explorer-section + :section section + :filename (get data "filename"))) (get data "sections")) (when (not (empty? (get data "platform-interface"))) @@ -78,56 +82,95 @@ (defcomp ~specs-explorer/spec-explorer-section - (&key section) + (&key section filename) (div - :class "mb-8" + :class "mb-6" (h2 - :class "text-lg font-semibold text-stone-700 border-b border-stone-200 pb-1 mb-3" + :class "text-base font-semibold text-stone-600 mb-2 border-b border-stone-200 pb-1" :id (replace (lower (get section "title")) " " "-") (get section "title")) (when (get section "comment") - (p :class "text-sm text-stone-500 mb-3" (get section "comment"))) + (p :class "text-sm text-stone-500 mb-2" (get section "comment"))) (div - :class "space-y-4" + :class "space-y-0.5" (map - (fn (d) (~specs-explorer/spec-explorer-define :d d)) + (fn + (d) + (~specs-explorer/spec-explorer-define :d d :filename filename)) (get section "defines"))))) (defcomp ~specs-explorer/spec-explorer-define - (&key d) + (&key d filename) (div - :class "rounded border border-stone-200 p-4" + :class "flex items-center gap-2 py-1.5 px-2 rounded hover:bg-stone-50 cursor-pointer group" :id (str "fn-" (get d "name")) + :sx-get (str + "/sx/(language.(spec.(explore." + (replace filename ".sx" "") + "." + (get d "name") + ")))") + :sx-target "#sx-content" + :sx-select "#sx-content" + :sx-swap "innerHTML" + :sx-push-url "true" + (span + :class "font-mono text-sm font-medium text-stone-800 group-hover:text-violet-700" + (get d "name")) + (span :class "text-xs text-stone-400" (get d "kind")))) + +(defcomp + ~specs-explorer/spec-explorer-define-detail + (&key d filename) + :affinity :server + (div + :class "max-w-3xl mx-auto" (div - :class "flex items-center gap-2 flex-wrap" - (span :class "font-mono font-semibold text-stone-800" (get d "name")) - (span :class "text-xs text-stone-400" (get d "kind")) - (if - (empty? (get d "effects")) + :class "mb-4" + (a + :class "text-sm text-violet-600 hover:text-violet-800" + :href (str + "/sx/(language.(spec.(explore." + (replace filename ".sx" "") + ")))") + :sx-get (str + "/sx/(language.(spec.(explore." + (replace filename ".sx" "") + ")))") + :sx-target "#sx-content" + :sx-select "#sx-content" + :sx-swap "innerHTML" + :sx-push-url "true" + (str "← Back to " (replace filename ".sx" "")))) + (div + :class "rounded border border-violet-200 bg-violet-50/30 p-4" + (div + :class "flex items-center gap-2 flex-wrap mb-3" (span - :class "text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700" - "pure") - (map - (fn (eff) (~specs-explorer/spec-effect-badge :effect eff)) - (get d "effects")))) - (when - (not (empty? (get d "params"))) - (~specs-explorer/spec-param-list :params (get d "params"))) - (~specs-explorer/spec-ring-translations - :source (get d "source") - :python (get d "python") - :javascript (get d "javascript") - :z3 (get d "z3")) - (when - (not (empty? (get d "refs"))) - (~specs-explorer/spec-ring-bridge :refs (get d "refs"))) - (when - (> (get d "test-count") 0) - (~specs-explorer/spec-ring-runtime - :tests (get d "tests") - :test-count (get d "test-count"))))) + :class "font-mono text-lg font-semibold text-stone-800" + (get d "name")) + (span :class "text-xs text-stone-400" (get d "kind")) + (if + (empty? (get d "effects")) + (span + :class "text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700" + "pure") + (map + (fn (eff) (~specs-explorer/spec-effect-badge :effect eff)) + (get d "effects")))) + (when + (not (empty? (get d "params"))) + (~specs-explorer/spec-param-list :params (get d "params"))) + (details + :open "true" + (summary + :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer select-none mt-3 rounded" + "SX Source") + (pre + :class "text-xs p-3 overflow-x-auto bg-white rounded mt-1 border border-stone-200" + (code :class "language-sx" (get d "source"))))))) (defcomp ~specs-explorer/spec-effect-badge @@ -178,13 +221,12 @@ (div :class "mt-3 border border-stone-200 rounded-lg overflow-hidden" (details - :open "true" (summary - :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer" - "SX") + :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer select-none" + "SX Source") (pre :class "text-xs p-3 overflow-x-auto bg-white" - (code (highlight source "sx")))) + (code :class "language-sx" source))) (when python (details diff --git a/tests/playwright/spec-explorer.spec.js b/tests/playwright/spec-explorer.spec.js new file mode 100644 index 00000000..72c49496 --- /dev/null +++ b/tests/playwright/spec-explorer.spec.js @@ -0,0 +1,74 @@ +const { test, expect } = require('playwright/test'); +const { BASE_URL, trackErrors } = require('./helpers'); + +const LOAD_TIMEOUT = 30000; + +async function loadExplorer(page, path) { + await page.goto(BASE_URL + '/sx/' + path, { + waitUntil: 'domcontentloaded', + timeout: LOAD_TIMEOUT, + }); + await page.waitForSelector('h1, .sx-render-error, [data-sx-boundary]', { + timeout: LOAD_TIMEOUT, + }); +} + +test.describe('Spec Explorer', () => { + test('overview loads with evaluator definitions', async ({ page }) => { + await loadExplorer(page, '(language.(spec.(explore.evaluator)))'); + + await expect(page.locator('h1')).toHaveText('Evaluator'); + await expect(page.locator('text=141 defines')).toBeVisible(); + await expect(page.locator('text=2501 lines')).toBeVisible(); + await expect(page.locator('h2:has-text("Definitions")')).toBeVisible(); + await expect(page.locator('#fn-make-cek-state')).toBeVisible(); + await expect(page.locator('#fn-cek-step')).toBeVisible(); + // eval-expr has 2 entries (forward decl + real), just check at least 1 + await expect(page.locator('#fn-eval-expr').first()).toBeVisible(); + await expect(page.locator('.sx-render-error')).toHaveCount(0); + }); + + test('no errors on initial load', async ({ page }) => { + const tracker = trackErrors(page); + await loadExplorer(page, '(language.(spec.(explore.evaluator)))'); + await expect(page.locator('h1')).toHaveText('Evaluator'); + expect(tracker.errors()).toEqual([]); + }); + + test('SPA nav to explorer has no render error', async ({ page }) => { + await page.goto(BASE_URL + '/sx/(language.(spec))', { + waitUntil: 'domcontentloaded', + timeout: LOAD_TIMEOUT, + }); + await page.waitForSelector('h1, h2', { timeout: LOAD_TIMEOUT }); + + const link = page.locator('a[href*="explore.evaluator"]'); + if (await link.count() > 0) { + await link.click(); + await page.waitForSelector('#fn-cek-step', { timeout: LOAD_TIMEOUT }); + await expect(page.locator('h1')).toHaveText('Evaluator'); + + const content = await page.locator('#sx-content').textContent(); + expect(content).not.toContain('Render error'); + expect(content).not.toContain('Not callable'); + } + }); + + test('drill-in shows definition detail', async ({ page }) => { + await loadExplorer(page, '(language.(spec.(explore.evaluator.cek-step)))'); + + await expect(page.locator('text=cek-step').first()).toBeVisible(); + await expect(page.locator('text=function').first()).toBeVisible(); + await expect(page.locator('a:has-text("Back to evaluator")')).toBeVisible(); + await expect(page.locator('text=define cek-step')).toBeVisible(); + }); + + test('drill-in shows params and pure badge', async ({ page }) => { + await loadExplorer(page, '(language.(spec.(explore.evaluator.make-cek-state)))'); + + await expect(page.locator('text=control').first()).toBeVisible(); + await expect(page.locator('text=env').first()).toBeVisible(); + await expect(page.locator('text=kont').first()).toBeVisible(); + await expect(page.locator('text=pure').first()).toBeVisible(); + }); +}); diff --git a/web/adapter-sx.sx b/web/adapter-sx.sx index 9382231f..9e4aece1 100644 --- a/web/adapter-sx.sx +++ b/web/adapter-sx.sx @@ -437,17 +437,9 @@ (let ((f (trampoline (eval-expr (first args) env))) (coll (trampoline (eval-expr (nth args 1) env)))) - (map - (fn - (item) - (if - (lambda? f) - (let - ((local (env-merge (lambda-closure f) env))) - (env-bind! local (first (lambda-params f)) item) - (aser (lambda-body f) local)) - (cek-call f (list item)))) - coll)) + (let + ((results (map (fn (item) (if (lambda? f) (let ((local (env-extend (lambda-closure f)))) (env-bind! local (first (lambda-params f)) item) (aser (lambda-body f) local)) (cek-call f (list item)))) coll))) + (aser-fragment results env))) (= name "map-indexed") (let ((f (trampoline (eval-expr (first args) env)))