Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
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>
219 lines
9.6 KiB
Plaintext
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}))
|