host/tests: Phase 3 — the engine renders the picker to a CONSOLE (non-browser target)

web/console-render.sx: render-to-console walks a live DOM element tree through the
engine's own dom-* accessors and prints it as terminal text — the results <ul>
becomes a bulleted list, the filter <input> a text field, the load-more sentinel a
"…" line, an .sx-error element a flagged line. It's the console platform's draw
step: the browser PAINTS the engine's tree, the harness ASSERTS it, this PRINTS it
— one tree, three bindings, the proof the engine is a general runtime not a browser
library.

Wired into the picker's SX engine tests (web/tests/test-relate-picker.sx): the load
and error tests now ALSO assert their console rendering — the same tree the engine
built drives both the DOM assertion and the terminal output, so Phase 1's suite is
the console renderer's regression suite for free. Plus a relate-picker:console suite
for the field/bullet/sentinel/error shapes. 7/7 green, no web-suite regressions.

(Class membership reads the live classList via dom-has-class?, not the static class
attribute — the engine adds .sx-error through classList.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 18:08:51 +00:00
parent 2b2073cf56
commit 16f90ffdad
3 changed files with 143 additions and 1 deletions

94
web/console-render.sx Normal file
View File

@@ -0,0 +1,94 @@
;; web/console-render.sx — render a live DOM element tree to terminal text.
;;
;; The SX hypermedia engine builds the SAME element tree whatever the platform: a
;; browser PAINTS that tree, the test harness ASSERTS it, and this PRINTS it. Three
;; bindings of one tree — the proof that the engine is a general runtime, not a
;; browser library (Phase 3 of plans/sx-native-engine-tests.md).
;;
;; It reads the tree through the engine's own dom-* accessors, so it renders
;; whatever the engine produced — e.g. the ~relate-picker after a load/filter/paging
;; swap: the results <ul> becomes a bulleted list, the filter <input> a text field,
;; the load-more sentinel a "…" line, and an .sx-error element a flagged line. The
;; same render-to-console is the console platform's draw step; the picker's SX engine
;; tests (web/tests/test-relate-picker.sx) double as its regression suite — they
;; already drive the tree, this just prints it.
;; class membership via the live classList (dom-has-class?), NOT the parsed `class`
;; attribute — the engine adds .sx-error through classList, which leaves the static
;; attribute untouched, so an attribute read would miss it.
(define cr/has-class?
(fn (el cls) (dom-has-class? el cls)))
;; The visible text of an element: its own textContent when it has no element
;; children, else the concatenation of its children's text (the mock parser hangs
;; leaf text on textContent, so this covers both row buttons and the sentinel).
(define cr/text-of
(fn (el)
(if (nil? el)
""
(let ((kids (dom-child-list el)))
(if (empty? kids)
(or (host-get el "textContent") "")
(let ((parts (map cr/text-of kids)))
(let ((joined (join "" parts)))
(if (= joined "")
(or (host-get el "textContent") "")
joined))))))))
;; A <ul>/<ol> renders one line per <li>: a bullet for a candidate row, an ellipsis
;; for the load-more sentinel (.rp-more).
(define cr/list-lines
(fn (el)
(map
(fn (li)
(if (cr/has-class? li "rp-more")
(str " … " (cr/text-of li))
(str " • " (cr/text-of li))))
(dom-child-list el))))
;; An <input> renders as a labelled text field; hidden inputs aren't drawn.
(define cr/input-line
(fn (el)
(let ((type (or (dom-get-attr el "type") "text")))
(if (= type "hidden")
""
(let ((label (or (dom-get-attr el "placeholder") (dom-get-attr el "name") ""))
(val (or (host-get el "value") "")))
(str label ": [" (if (= val "") " " val) "]"))))))
;; Flatten the rendered lines of an element's children, dropping blanks.
(define cr/children-lines
(fn (el)
(let ((out (list)))
(for-each
(fn (kid)
(for-each
(fn (ln) (when (not (= ln "")) (append! out ln)))
(cr/lines kid)))
(dom-child-list el))
out)))
;; Render one element to a list of text lines, tag-driven. An .sx-error element is
;; flagged with a leading status line (the terminal's red line).
(define cr/lines
(fn (el)
(if (nil? el)
(list)
(let ((tag (lower (dom-tag-name el)))
(err (cr/has-class? el "sx-error")))
(let
((body
(cond
(or (= tag "ul") (= tag "ol")) (cr/list-lines el)
(= tag "input") (let ((l (cr/input-line el))) (if (= l "") (list) (list l)))
(= tag "button") (list (str "[ " (cr/text-of el) " ]"))
(or (= tag "h1") (= tag "h2") (= tag "h3")) (list (upper (cr/text-of el)))
:else (cr/children-lines el))))
(if err
(cons "✖ connection problem — retrying…" body)
body))))))
;; render-to-console — the DOM-tree → terminal-text mode, alongside render-to-html /
;; render-to-dom. Returns the rendered tree as a single newline-joined string.
(define render-to-console
(fn (el) (join "\n" (cr/lines el))))

View File

@@ -117,7 +117,7 @@
" sx-swap=\"innerHTML\""
" sx-retry=\"exponential:1000:30000\">"
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
"<input type=\"text\" name=\"q\" class=\"rp-filter\">"
"<input type=\"text\" name=\"q\" class=\"rp-filter\" placeholder=\"filter…\">"
"<ul id=\"rp-" kind "-results\" class=\"rp-results\"></ul>"
"</form>")))
@@ -200,6 +200,12 @@
(process-elements root)
(assert-true (> _mock-fetch-calls 0))
(assert-equal 5 (count-candidates results))
;; Phase 3: the SAME populated tree renders to the console — the results
;; <ul> becomes a bulleted list of candidate titles. (Console renderer driven
;; for free by the engine tree; web/console-render.sx.)
(let ((txt (render-to-console results)))
(assert-true (contains? txt "• Picker Item 0"))
(assert-true (contains? txt "• Picker Item 4")))
(clear-root! root))))
(defsuite
@@ -272,6 +278,9 @@
;; visible failure state: .sx-error lands on the picker form
(assert-true (dom-has-class? form "sx-error"))
(assert-equal 0 (count-candidates results))
;; Phase 3: the console rendering of the errored picker shows the failure as
;; a flagged line (the terminal's "red line") — same tree, different binding.
(assert-true (contains? (render-to-console form) "✖"))
;; recovery: the endpoint works again, the next input retries and the error
;; clears as the results populate
(set! _mock-fetch-fail false)
@@ -280,3 +289,41 @@
(assert-false (dom-has-class? form "sx-error"))
(assert-equal 3 (count-candidates results))
(clear-root! root))))
;; ── Phase 3: the engine drives a non-browser target (the console) ───
;; render-to-console (web/console-render.sx) prints the live engine tree as text.
;; These assert the picker's terminal rendering directly on a built tree — the
;; console platform's draw step, proven without a terminal.
(defsuite
"relate-picker:console"
(deftest
"the picker form renders as a filter field over a bulleted candidate list"
(let
((root (mk-root (str
"<form class=\"relate-picker\">"
"<input type=\"hidden\" name=\"kind\" value=\"related\">"
"<input type=\"text\" name=\"q\" class=\"rp-filter\" placeholder=\"filter…\">"
"<ul class=\"rp-results\">"
(row-html "host" "related" "item-0" "Picker Item 0")
(row-html "host" "related" "item-1" "Picker Item 1")
(sentinel-html "host" "related" 20)
"</ul>"
"</form>")))
(form (dom-query ".relate-picker")))
(let ((txt (render-to-console form)))
;; the text input becomes a labelled field (placeholder as the label)...
(assert-true (contains? txt "filter…: ["))
;; ...the hidden kind input is not drawn...
(assert-false (contains? txt "related: ["))
;; ...each candidate is a bullet, the sentinel an ellipsis line
(assert-true (contains? txt "• Picker Item 0"))
(assert-true (contains? txt "• Picker Item 1"))
(assert-true (contains? txt "… Loading more…")))
(clear-root! root)))
(deftest
"an empty results list renders no bullets"
(let
((root (mk-root "<ul class=\"rp-results\"></ul>"))
(results (dom-query root ".rp-results"))) ;; scope to this root
(assert-equal "" (render-to-console results))
(clear-root! root))))