Files
rose-ash/lib/host/tests/sxtp.sx
giles 1d02afb64a
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
sxtp: patch + signals primitives (Datastar-borrowed)
Adds two new top-level SXTP message types alongside
request/response/condition/event, modelled on Datastar's
datastar-patch-elements and datastar-patch-signals SSE events:

  (patch :target "#x" :mode outer :body (~card)) - DOM fragment
    morph. Subsumes HTMX swap modes. Mode is outer (default) |
    inner | replace | prepend | append | before | after | remove.

  (signals :values {:n 3} :only-if-missing false) - reactive
    state patch. nil value removes the signal. only-if-missing
    skips existing signals (lazy init).

A server response stream can mix both freely; clients dispatch
by head symbol, ordering preserved. Cleaner than HTMX's
swap-mode-per-trigger because the patch shape is decoupled from
the triggering element/attribute.

Spec at applications/sxtp/spec.sx (patch-fields, signals-fields,
patch-modes, example-patch-stream). Constructors / predicates /
accessors / serialise / parse in lib/host/sxtp.sx. 25 new tests
in lib/host/tests/sxtp.sx (predicates, mode normalisation, fixed
field order, remove-without-body, signals round-trip). Host
conformance 129/129 (was 104/104).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 15:22:37 +00:00

219 lines
9.6 KiB
Plaintext

;; lib/host/tests/sxtp.sx — SXTP message algebra, wire serialise/parse round-trip,
;; and the Dream HTTP <-> SXTP bridge.
(define host-sx-pass 0)
(define host-sx-fail 0)
(define host-sx-fails (list))
(define
host-sx-test
(fn
(name actual expected)
(if
(= actual expected)
(set! host-sx-pass (+ host-sx-pass 1))
(begin
(set! host-sx-fail (+ host-sx-fail 1))
(append! host-sx-fails {:name name :actual actual :expected expected})))))
;; ── constructors + predicates ──────────────────────────────────────
(define host-sx-req (sxtp/request "navigate" "/x" {:headers {:host "h"}}))
(define host-sx-resp (sxtp/ok {:id "e1"}))
(host-sx-test "request?" (sxtp/request? host-sx-req) true)
(host-sx-test "request not response" (sxtp/response? host-sx-req) false)
(host-sx-test "response?" (sxtp/response? host-sx-resp) true)
(host-sx-test "condition?" (sxtp/condition? (sxtp/condition "x" {})) true)
(host-sx-test "patch?" (sxtp/patch? (sxtp/patch "#x" {})) true)
(host-sx-test "patch not event" (sxtp/event? (sxtp/patch "#x" {})) false)
(host-sx-test "signals?" (sxtp/signals? (sxtp/signals {:n 3} {})) true)
(host-sx-test "signals not patch" (sxtp/patch? (sxtp/signals {:n 3} {})) false)
;; ── accessors (verb/status are symbols) ────────────────────────────
(host-sx-test "verb" (symbol->string (sxtp/verb host-sx-req)) "navigate")
(host-sx-test "path" (sxtp/path host-sx-req) "/x")
(host-sx-test "req header" (get (sxtp/req-headers host-sx-req) :host) "h")
(host-sx-test "status" (symbol->string (sxtp/status host-sx-resp)) "ok")
(host-sx-test "body" (get (sxtp/body host-sx-resp) :id) "e1")
;; ── status helpers ─────────────────────────────────────────────────
(host-sx-test "created status" (symbol->string (sxtp/status (sxtp/created {}))) "created")
(host-sx-test
"not-found status"
(symbol->string (sxtp/status (sxtp/not-found "/p" "gone")))
"not-found")
(host-sx-test
"not-found body is condition"
(sxtp/condition? (sxtp/body (sxtp/not-found "/p" "gone")))
true)
(host-sx-test
"forbidden message"
(sxtp/cond-message (sxtp/body (sxtp/forbidden "no")))
"no")
;; ── serialise (deterministic top-level field order) ────────────────
(host-sx-test
"serialize request"
(sxtp/serialize host-sx-req)
"(request :verb navigate :path \"/x\" :headers {:host \"h\"})")
(host-sx-test
"serialize ok"
(sxtp/serialize (sxtp/ok {:id "e1"}))
"(response :status ok :body {:id \"e1\"})")
;; nested condition rides the wire in its (condition ...) list form, no :msg leak.
(host-sx-test
"serialize nested condition as list"
(contains?
(sxtp/serialize (sxtp/not-found "/p" "gone"))
"(condition :type resource-not-found")
true)
(host-sx-test
"serialize no :msg leak"
(contains? (sxtp/serialize host-sx-resp) ":msg")
false)
;; ── patch + signals (Datastar-borrowed) ───────────────────────────
;; Mode defaults to outer; accepts string OR symbol input.
(host-sx-test
"patch default mode is outer symbol"
(symbol->string (sxtp/mode (sxtp/patch "#x" {})))
"outer")
(host-sx-test
"patch accepts symbol mode"
(symbol->string (sxtp/mode (sxtp/patch "#x" {:mode (string->symbol "inner")})))
"inner")
(host-sx-test
"patch accepts string mode and normalises"
(symbol->string (sxtp/mode (sxtp/patch "#x" {:mode "append"})))
"append")
(host-sx-test
"patch target accessor"
(sxtp/target (sxtp/patch "#cart" {}))
"#cart")
(host-sx-test
"patch serialises with target/mode/body in fixed order"
(sxtp/serialize (sxtp/patch "#x" {:body "hi"}))
"(patch :target \"#x\" :mode outer :body \"hi\")")
(host-sx-test
"patch remove mode serialises without :body"
(sxtp/serialize (sxtp/patch "#x" {:mode "remove"}))
"(patch :target \"#x\" :mode remove)")
(host-sx-test
"patch transition? predicate"
(sxtp/transition? (sxtp/patch "#x" {:transition true}))
true)
(host-sx-test
"signals accessor"
(get (sxtp/values (sxtp/signals {:cart/count 3} {})) :cart/count)
3)
(host-sx-test
"signals only-if-missing default false"
(sxtp/only-if-missing? (sxtp/signals {:n 1} {}))
false)
(host-sx-test
"signals only-if-missing true round-trips"
(sxtp/only-if-missing? (sxtp/signals {:n 1} {:only-if-missing true}))
true)
(host-sx-test
"signals serialise"
(sxtp/serialize (sxtp/signals {:cart/count 3} {}))
"(signals :values {:cart/count 3})")
;; ── round-trip ────────────────────────────────────────────────────
(define host-sx-patch-rt
(sxtp/parse (sxtp/serialize (sxtp/patch "#mini" {:mode "inner" :body "n=3"}))))
(host-sx-test "patch rt msg" (sxtp/patch? host-sx-patch-rt) true)
(host-sx-test "patch rt target" (sxtp/target host-sx-patch-rt) "#mini")
(host-sx-test "patch rt mode" (symbol->string (sxtp/mode host-sx-patch-rt)) "inner")
(define host-sx-signals-rt
(sxtp/parse (sxtp/serialize (sxtp/signals {:a 1 :b "x"} {:only-if-missing true}))))
(host-sx-test "signals rt msg" (sxtp/signals? host-sx-signals-rt) true)
(host-sx-test "signals rt values"
(get (sxtp/values host-sx-signals-rt) :a) 1)
(host-sx-test "signals rt only-if-missing"
(sxtp/only-if-missing? host-sx-signals-rt) true)
;; ── parse + round-trip ─────────────────────────────────────────────
(define host-sx-parsed
(sxtp/parse "(request :verb query :path \"/events\" :headers {:host \"h\"})"))
(host-sx-test "parse msg type" (sxtp/request? host-sx-parsed) true)
(host-sx-test "parse verb" (symbol->string (sxtp/verb host-sx-parsed)) "query")
(host-sx-test "parse path" (sxtp/path host-sx-parsed) "/events")
(host-sx-test
"parse nested header normalised"
(get (sxtp/req-headers host-sx-parsed) :host)
"h")
(define host-sx-rt (sxtp/parse (sxtp/serialize (sxtp/ok {:id "e1" :n 3}))))
(host-sx-test "round-trip status" (symbol->string (sxtp/status host-sx-rt)) "ok")
(host-sx-test "round-trip body id" (get (sxtp/body host-sx-rt) :id) "e1")
(host-sx-test "round-trip body n" (get (sxtp/body host-sx-rt) :n) 3)
;; ── HTTP <-> SXTP mappings ─────────────────────────────────────────
(host-sx-test "verb GET->fetch" (symbol->string (sxtp/verb-for-method "GET")) "fetch")
(host-sx-test "verb POST->create" (symbol->string (sxtp/verb-for-method "POST")) "create")
(host-sx-test "verb DELETE->delete" (symbol->string (sxtp/verb-for-method "DELETE")) "delete")
(host-sx-test "verb unknown->fetch" (symbol->string (sxtp/verb-for-method "WIBBLE")) "fetch")
(host-sx-test "http ok->200" (sxtp/http-status (string->symbol "ok")) 200)
(host-sx-test "http not-found->404" (sxtp/http-status (string->symbol "not-found")) 404)
;; ── Dream bridge ───────────────────────────────────────────────────
(define host-sx-from
(sxtp/from-dream (dream-request "POST" "/feed?a=1" {} "hi")))
(host-sx-test "from-dream verb" (symbol->string (sxtp/verb host-sx-from)) "create")
(host-sx-test "from-dream path" (sxtp/path host-sx-from) "/feed")
(host-sx-test "from-dream param" (sxtp/param host-sx-from "a") "1")
(host-sx-test "from-dream body" (sxtp/body host-sx-from) "hi")
(define host-sx-tod (sxtp/to-dream (sxtp/ok {:id "e1"})))
(host-sx-test "to-dream status" (dream-status host-sx-tod) 200)
(host-sx-test
"to-dream content-type text/sx"
(dream-resp-header host-sx-tod "content-type")
"text/sx")
(host-sx-test
"to-dream body is sx text"
(dream-resp-body host-sx-tod)
"{:id \"e1\"}")
(host-sx-test
"to-dream not-found->404"
(dream-status (sxtp/to-dream (sxtp/not-found "/p" "gone")))
404)
(host-sx-test
"to-dream forbidden->403"
(dream-status (sxtp/to-dream (sxtp/forbidden "no")))
403)
;; ── engine<->server write wire: serialize (engine) <-> host/sx-body (server) ──
;; A boosted form posts (serialize {field->value}) as text/sx; the server reads it
;; back with host/sx-body. This is the SX write wire, verified with NO DOM (client-
;; agnostic): what the engine's serialize emits, host/sx-body must parse back
;; losslessly — including sx_content full of the quotes/parens that would break a
;; naive encoder. (The server side is what conformance can prove; the DOM field-read
;; is the one irreducibly-browser bit, left to a Playwright smoke.)
(define host-sx-wire-content "(article (h1 \"Title\") (p \"He said \\\"hi\\\" (x)\"))")
(define host-sx-wire-req
(dream-request "POST" "/x" {:content-type "text/sx"}
(serialize {:title "Hi there" :sx_content host-sx-wire-content :status "published"})))
(host-sx-test "sx-body round-trips a serialized field dict"
(get (host/sx-body host-sx-wire-req) "title") "Hi there")
(host-sx-test "sx-body preserves quoted/parenthesised sx_content losslessly"
(get (host/sx-body host-sx-wire-req) "sx_content") host-sx-wire-content)
(host-sx-test "field reads a text/sx body by content-type"
(host/field host-sx-wire-req "status") "published")
(host-sx-test "field falls back to urlencoded form (the no-engine path)"
(host/field (dream-request "POST" "/x"
{:content-type "application/x-www-form-urlencoded"}
"title=From+Form&status=draft") "title")
"From Form")
(define
host-sx-tests-run!
(fn
()
{:total (+ host-sx-pass host-sx-fail)
:passed host-sx-pass
:failed host-sx-fail
:fails host-sx-fails}))