;; lib/host/tests/blog.sx — blog on the editor's content model. Posts are
;; {slug,title,sx_content,status} records in the durable KV; a post page is
;; render-to-html(parse sx_content). Covers read/render, home index, JSON list,
;; slugify, the form-urlencoded editor ingest, and JSON CRUD (auth+ACL guarded).

(define host-bl-pass 0)
(define host-bl-fail 0)
(define host-bl-fails (list))
(define
  host-bl-test
  (fn (name actual expected)
    (if (= actual expected)
      (set! host-bl-pass (+ host-bl-pass 1))
      (begin
        (set! host-bl-fail (+ host-bl-fail 1))
        (append! host-bl-fails {:name name :actual actual :expected expected})))))

(define host-bl-req (fn (target) (dream-request "GET" target {} "")))
(define host-bl-app (host/make-app (list host/feed-routes host/blog-routes)))

;; ── slugify ─────────────────────────────────────────────────────────
(host-bl-test "slugify" (host/blog-slugify "Hello World") "hello-world")
(host-bl-test "slugify trims spaces" (host/blog-slugify "  A  B ") "a-b")

;; ── render a stored post ────────────────────────────────────────────
(host/blog-use-store! (persist/open))
(host/blog-put! "hello" "Hello World"
  "(article (h1 \"Hello World\") (p \"A \" (strong \"bold\") \" word.\"))" "published")

(host-bl-test "post 200" (dream-status (host-bl-app (host-bl-req "/hello/"))) 200)
(host-bl-test "post content-type html"
  (contains? (dream-resp-header (host-bl-app (host-bl-req "/hello/")) "content-type") "text/html")
  true)
(host-bl-test "post renders sx_content markup"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/hello/"))) "<strong>bold</strong>")
  true)
(host-bl-test "post title in page"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/hello/"))) "<title>Hello World</title>")
  true)

;; ── home + list ─────────────────────────────────────────────────────
(host-bl-test "home lists post"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/"))) "href=\"/hello/\"")
  true)
(host-bl-test "sx list shows post"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/posts"))) ":slug \"hello\"")
  true)
(host-bl-test "GET /new shows form"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/new"))) "<form")
  true)

;; ── unknown + precedence ────────────────────────────────────────────
(host-bl-test "unknown slug 404" (dream-status (host-bl-app (host-bl-req "/nope/"))) 404)
(feed/reset!)
(host-bl-test "/feed not captured by :slug"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/feed"))) ":ok true")
  true)

;; ── writes: editor form ingest + JSON CRUD (auth+ACL) ───────────────
(acl/load! (list (acl-grant "editor" "edit" "blog")))
(define host-bl-resolve
  (fn (tok) (cond ((= tok "good") "editor") ((= tok "weak") "reader") (true nil))))
(define host-bl-wapp
  (host/make-app (list (host/blog-write-routes host-bl-resolve) host/blog-routes)))
(define host-bl-send
  (fn (method target auth ctype body)
    (dream-request method target
      (merge (if auth {:authorization auth} {}) (if ctype {:content-type ctype} {})) body)))

(host/blog-use-store! (persist/open))

;; -- editor form ingest (form-urlencoded, the editor's submit shape) --
(host-bl-test "form ingest no auth -> redirect to login"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/new" nil
    "application/x-www-form-urlencoded" "title=X")))
  303)
(host-bl-test "form ingest no auth Location is /login"
  (contains? (dream-resp-header (host-bl-wapp (host-bl-send "POST" "/new" nil
    "application/x-www-form-urlencoded" "title=X")) "location") "/login")
  true)
(host-bl-test "form ingest authed -> 303 redirect"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
    "application/x-www-form-urlencoded"
    "title=My+First+Post&sx_content=(article+(h1+%22My+First+Post%22)+(p+%22Hi%22))&status=published")))
  303)
(host-bl-test "form ingest set Location to the new slug"
  (dream-resp-header
    (host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
      "application/x-www-form-urlencoded"
      "title=Another+One&sx_content=(p+%22x%22)&status=published"))
    "location")
  "/another-one/")
(host-bl-test "ingested post renders"
  (contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "<h1>My First Post</h1>")
  true)

;; (JSON CRUD tests removed — the /posts JSON create/update/delete endpoints were
;; deleted in the SX-native pivot; create + edit go through the form ingest above.)

;; -- write-time validation: malformed sx_content rejected, never stored --
;; "%3Ch1+broken%29" decodes to "<h1 broken)" — a typo'd paren the parser rejects.
(host-bl-test "form ingest malformed sx_content -> 400"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
    "application/x-www-form-urlencoded"
    "title=Bad+Form&sx_content=%3Ch1+broken%29&status=published")))
  400)
(host-bl-test "rejected form post was not stored"
  (dream-status (host-bl-wapp (host-bl-req "/bad-form/")))
  404)
;; (JSON malformed-content tests removed with the JSON CRUD endpoints; the form
;; ingest malformed-content checks above still cover write-time validation.)

;; -- view source (public) --
(host-bl-test "view source -> 200"
  (dream-status (host-bl-wapp (host-bl-req "/my-first-post/source"))) 200)
(host-bl-test "view source is text/plain"
  (dream-resp-header (host-bl-wapp (host-bl-req "/my-first-post/source")) "content-type")
  "text/plain; charset=utf-8")
(host-bl-test "view source returns raw sx_content"
  (contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/source"))) "(article")
  true)
(host-bl-test "view source missing -> 404"
  (dream-status (host-bl-wapp (host-bl-req "/ghost/source"))) 404)
(host-bl-test "/:slug not shadowed by /:slug/source"
  (dream-status (host-bl-wapp (host-bl-req "/my-first-post/"))) 200)

;; -- edit source (guarded GET form + guarded POST save) --
(host-bl-test "edit form no auth -> redirect to login"
  (dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" ""))) 303)
(host-bl-test "edit form no auth Location carries next=/…/edit"
  (contains?
    (dream-resp-header (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" "")) "location")
    "/login?next=/my-first-post/edit")
  true)
(host-bl-test "edit form authed -> 200"
  (dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" ""))) 200)
(host-bl-test "edit form shows current source"
  (contains? (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" "")))
    "(article")
  true)
(host-bl-test "edit submit no auth -> redirect to login"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" nil
    "application/x-www-form-urlencoded" "sx_content=(p+%22x%22)"))) 303)
(host-bl-test "edit submit authed -> 303"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
    "application/x-www-form-urlencoded"
    "title=My+First+Post&sx_content=(p+%22edited+via+editor%22)&status=published"))) 303)
(host-bl-test "edit persisted the new content"
  (contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "edited via editor")
  true)
(host-bl-test "edit preserves the slug"
  (dream-resp-header
    (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
      "application/x-www-form-urlencoded" "title=Renamed&sx_content=(p+%22y%22)&status=draft"))
    "location")
  "/my-first-post/")
(host-bl-test "edit malformed body -> 400"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
    "application/x-www-form-urlencoded" "sx_content=%3Ch1+broken%29"))) 400)
(host-bl-test "edit missing post -> 404"
  (dream-status (host-bl-wapp (host-bl-send "GET" "/ghost/edit" "Bearer good" "" ""))) 404)

;; -- auth footer (discoverable login/logout) --
(host-bl-test "home footer shows a log in link when anonymous"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/"))) ">log in</a>") true)
(host-bl-test "post footer shows a log in link when anonymous"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/my-first-post/"))) ">log in</a>") true)
(host-bl-test "GET /logout -> 303"
  (dream-status (host-bl-app (host-bl-req "/logout"))) 303)

;; -- relate posts (blog × relations) --
;; my-first-post and another-one both exist in the write-test store at this point.
;; Relations are posts now (their symmetry/labels live on relation-posts), so seed
;; them up front exactly as boot does (serve.sh) before exercising relate, and load
;; the relation metadata into the in-memory cache the same way.
(host/blog-seed-types!)
(host/blog-load-rel-kinds!)
(host-bl-test "relate no auth -> redirect to login"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" nil
    "application/x-www-form-urlencoded" "other=another-one"))) 303)
(host-bl-test "relate authed -> 303 back to edit"
  (dream-resp-header (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" "Bearer good"
    "application/x-www-form-urlencoded" "other=another-one")) "location")
  "/my-first-post/edit")
(host-bl-test "related is symmetric (a -> b)"
  (contains? (host/blog-related "my-first-post") "another-one") true)
(host-bl-test "related is symmetric (b -> a)"
  (contains? (host/blog-related "another-one") "my-first-post") true)
(host-bl-test "post page shows a Related posts block"
  (contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "Related posts") true)
(host-bl-test "post page links the related post"
  (contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "/another-one/") true)
(host-bl-test "relate nonexistent other -> no-op"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" "Bearer good"
      "application/x-www-form-urlencoded" "other=ghost-post"))
    (contains? (host/blog-related "my-first-post") "ghost-post"))
  false)
(host-bl-test "unrelate -> removes the link both ways"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/my-first-post/unrelate" "Bearer good"
      "application/x-www-form-urlencoded" "other=another-one"))
    (list (contains? (host/blog-related "my-first-post") "another-one")
          (contains? (host/blog-related "another-one") "my-first-post")))
  (list false false))
;; (The "delete cleans up related edges" test was removed with the JSON DELETE
;; /posts endpoint; cascade edge cleanup returns when a browser delete route is
;; added — see the FOLLOW-UP note in lib/host/blog.sx.)

;; -- relate picker (filterable candidate endpoint + glue + hint) --
(host/blog-put! "alpha-post" "Alpha Post" "(p \"a\")" "published")
(host/blog-put! "beta-post" "Beta Post" "(p \"b\")" "published")
(host/blog-put! "gamma-post" "Gamma Post" "(p \"g\")" "published")
(host-bl-test "relate-options lists other posts"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "Beta Post") true)
(host-bl-test "relate-options excludes the post itself"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) ">Alpha Post<") false)
(host-bl-test "relate-options filters by q (title substring)"
  (let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=beta")))))
    (list (contains? body "Beta Post") (contains? body "Gamma Post")))
  (list true false))
(host-bl-test "relate-options filter url-decodes q (spaces)"
  (let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=Beta%20Post")))))
    (list (contains? body "Beta Post") (contains? body "Gamma Post")))
  (list true false))
(host-bl-test "relate-options excludes already-related candidates"
  (begin
    (host/blog-relate! "alpha-post" "beta-post" "related")
    (contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "Beta Post"))
  false)
(host/blog-unrelate! "alpha-post" "beta-post" "related")
;; The picker is a declarative SX-htmx form (no client JS): the form GETs
;; relate-options serialising kind + the filter q, swapping the results ul on
;; "load" and on debounced "input". The SX engine re-binds these triggers on
;; swapped content, so it works on a full load AND a boosted SPA nav.
(host-bl-test "picker form is declaratively wired to relate-options (load + debounced input)"
  (let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
    (list (contains? html "/alpha-post/relate-options")
          (contains? html "input delay:200ms, load")
          (contains? html "rp-related-results")))
  (list true true true))
;; the editor server-renders the first page of candidates INTO the picker's results
;; <ul>, so a re-rendered editor is never briefly empty (no flash). The candidate row
;; for an existing post appears inside the results ul.
(host-bl-test "editor server-renders the first page of candidates into the picker"
  (let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
    (list (contains? html "id=\"cand-related-")          ;; a candidate row is present
          (contains? html "Beta Post")))                 ;; an unrelated post is offered
  (list true true))
;; Paging is server-driven: a full page carries a "load more" sentinel that, when
;; revealed, GETs the next page and replaces itself (outerHTML), preserving q.
(host-bl-test "load-more sentinel: revealed, outerHTML-swap, next offset, preserved q"
  (let ((html (render-page (host/blog--picker-more "alpha-post" "related" "my q" 20))))
    (list (contains? html "rp-more")
          (contains? html "revealed")
          (contains? html "outerHTML")
          (contains? html "offset=20")
          (contains? html "q=my%20q")
          (contains? html "exponential:1000:30000")))   ;; retries a dropped fetch
  (list true true true true true true))
(host-bl-test "relate-options omits the load-more sentinel on a short last page"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "rp-more")
  false)

;; -- relate / unrelate keep BOTH lists in sync by re-rendering the kind's editor.
;;    Regressions: (1) relating a candidate must ADD it to the current-relations
;;    list (not just delete the candidate row); (2) removing must NOT clear the
;;    relate picker. Both the candidate's relate form and the remove form target
;;    #rel-editor-KIND with sx-swap=outerHTML; the handler returns the re-rendered
;;    editor, so the current list updates and the fresh picker re-loads. --
(host/blog-relate! "alpha-post" "beta-post" "related")
;; the editor wraps current list + picker in #rel-editor-KIND; remove re-renders it
(host-bl-test "relation-editor wires remove to re-render the kind's editor"
  (let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
    (list (contains? html "id=\"rel-editor-related\"")           ;; the swap target
          (contains? html "sx-post=\"/alpha-post/unrelate\"")    ;; AJAX, not plain post
          (contains? html "sx-target=\"#rel-editor-related\"")
          (contains? html "sx-swap=\"outerHTML\"")))
  (list true true true true))
;; the candidate's relate form targets the SAME editor (so relating re-renders it)
(host-bl-test "picker candidate relate form re-renders the kind's editor"
  (let ((html (render-page (host/blog--picker-item "alpha-post" {:slug "gamma-post" :title "Gamma"} "related"))))
    (list (contains? html "sx-post=\"/alpha-post/relate\"")
          (contains? html "sx-target=\"#rel-editor-related\"")
          (contains? html "sx-swap=\"outerHTML\"")))
  (list true true true))
;; a POST request to a /:slug/… route, with the :slug route param populated (which
;; the route matcher would set) plus headers + a form body.
(define host-bl-relreq
  (fn (slug action headers other kind)
    (merge (dream-request "POST" (str "/" slug "/" action) headers
             (str "other=" other "&kind=" kind))
           {:params {:slug slug}})))
;; the AJAX remove (carries SX-Target) returns the re-rendered editor fragment (200,
;; with the #rel-editor wrapper + the picker) — not an empty body or a redirect.
(host-bl-test "unrelate (AJAX, SX-Target) returns the re-rendered editor fragment"
  (let ((resp (host/blog-unrelate-submit
                (host-bl-relreq "alpha-post" "unrelate"
                  {:sx-request "true" :sx-target "#rel-editor-related"}
                  "beta-post" "related"))))
    (list (dream-status resp)
          (contains? (dream-resp-body resp) "rel-editor-related")
          (contains? (dream-resp-body resp) "relate-picker")))
  (list 200 true true))
;; relate (AJAX, SX-Target) likewise returns the editor with the new relation listed
(host/blog-unrelate! "alpha-post" "gamma-post" "related")    ;; clean state
(host-bl-test "relate (AJAX, SX-Target) returns the editor showing the new relation"
  (let ((resp (host/blog-relate-submit
                (host-bl-relreq "alpha-post" "relate"
                  {:sx-request "true" :sx-target "#rel-editor-related"}
                  "gamma-post" "related"))))
    (list (dream-status resp)
          (contains? (dream-resp-body resp) "/gamma-post/")))   ;; now in the current list
  (list 200 true))
(host/blog-unrelate! "alpha-post" "gamma-post" "related")
;; a plain boosted form / no-JS POST (no SX-Target) still redirects + re-renders,
;; so the is-a-tag toggle and graceful degradation are unaffected.
(host-bl-test "unrelate (plain boosted / no-JS, no SX-Target) still redirects"
  (dream-status (host/blog-unrelate-submit
                  (host-bl-relreq "alpha-post" "unrelate"
                    {:sx-request "true"} "beta-post" "related")))
  303)
(host/blog-unrelate! "alpha-post" "beta-post" "related")
(host/blog-put! "hint-post" "Hint Post" "(p \"h\")" "published")
(host-bl-test "relations section: hint when logged-in + no relations"
  (contains? (str (host/blog--relations-or-hint "hint-post" true)) "add some") true)
(host-bl-test "relations section: empty when anonymous + no relations"
  (= (host/blog--relations-or-hint "hint-post" false) "") true)

;; -- Phase 1: relations carry a kind --
(host-bl-test "symmetric kind (related) reads from both sides"
  (begin
    (host/blog-relate! "alpha-post" "gamma-post" "related")
    (list (contains? (host/blog-out "alpha-post" "related") "gamma-post")
          (contains? (host/blog-out "gamma-post" "related") "alpha-post")))
  (list true true))
(host-bl-test "directed kind (tagged) writes one direction; inverse via host/blog-in"
  (begin
    (host/blog-relate! "alpha-post" "beta-post" "tagged")
    (list (contains? (host/blog-out "alpha-post" "tagged") "beta-post")
          (contains? (host/blog-out "beta-post" "tagged") "alpha-post")
          (contains? (host/blog-in "beta-post" "tagged") "alpha-post")))
  (list true false true))
(host-bl-test "unrelate is kind-scoped (related edge survives a tagged unrelate)"
  (begin
    (host/blog-unrelate! "alpha-post" "beta-post" "tagged")
    (list (contains? (host/blog-out "alpha-post" "tagged") "beta-post")
          (contains? (host/blog-out "alpha-post" "related") "gamma-post")))
  (list false true))
(host/blog-unrelate! "alpha-post" "gamma-post" "related")
(host-bl-test "relate-submit rejects an unknown kind (no-op)"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/alpha-post/relate" "Bearer good"
      "application/x-www-form-urlencoded" "other=beta-post&kind=bogus"))
    (contains? (host/blog-out "alpha-post" "bogus") "beta-post"))
  false)
(host-bl-test "default kind is related (no kind field)"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/alpha-post/relate" "Bearer good"
      "application/x-www-form-urlencoded" "other=beta-post"))
    (contains? (host/blog-out "alpha-post" "related") "beta-post"))
  true)
(host-bl-test "edges are durable: KV row written on relate"
  (begin
    (host/blog-relate! "alpha-post" "gamma-post" "tagged")
    (persist/backend-kv-has? host/blog-store (host/blog--edge-key "alpha-post" "tagged" "gamma-post")))
  true)
(host-bl-test "replay rebuilds the graph after an in-memory wipe (restart sim)"
  (begin
    (relations/load! (list))                          ;; simulate a fresh process
    (host/blog-load-edges!)                            ;; replay from the durable store
    (list (contains? (host/blog-out "alpha-post" "tagged") "gamma-post")
          (contains? (host/blog-out "alpha-post" "related") "beta-post")
          (contains? (host/blog-out "beta-post" "related") "alpha-post")))
  (list true true true))
(host-bl-test "unrelate deletes the durable KV row"
  (begin
    (host/blog-unrelate! "alpha-post" "gamma-post" "tagged")
    (persist/backend-kv-has? host/blog-store (host/blog--edge-key "alpha-post" "tagged" "gamma-post")))
  false)

;; -- Phase 2: typing with subsumption (is-a + subtype-of) --
;; ppost --is-a--> ptutorial ; ptutorial --subtype-of--> particle --subtype-of--> pdoc
(host/blog-put! "ptutorial" "P Tutorial" "(p \"t\")" "published")
(host/blog-put! "particle" "P Article" "(p \"a\")" "published")
(host/blog-put! "pdoc" "P Doc" "(p \"d\")" "published")
(host/blog-put! "ppost" "P Post" "(p \"p\")" "published")
(host/blog-relate! "ptutorial" "particle" "subtype-of")
(host/blog-relate! "particle" "pdoc" "subtype-of")
(host/blog-relate! "ppost" "ptutorial" "is-a")
(host-bl-test "types-of = declared type + ALL its subtype-of supertypes"
  (list (contains? (host/blog-types-of "ppost") "ptutorial")
        (contains? (host/blog-types-of "ppost") "particle")
        (contains? (host/blog-types-of "ppost") "pdoc"))
  (list true true true))
(host-bl-test "is-a? is transitive THROUGH subtype-of (subsumption)"
  (list (host/blog-is-a? "ppost" "ptutorial")
        (host/blog-is-a? "ppost" "pdoc"))
  (list true true))
(host-bl-test "is-a? alone does NOT chain (instance-of is not transitive)"
  (begin
    (host/blog-put! "pmeta" "P Meta" "(p \"m\")" "published")
    (host/blog-relate! "pmeta" "ppost" "is-a")        ;; pmeta is-a ppost is-a ptutorial
    (host/blog-is-a? "pmeta" "ptutorial"))             ;; ... does NOT make pmeta is-a ptutorial
  false)
(host-bl-test "is-a? false for an unrelated type"
  (host/blog-is-a? "ppost" "particle") true)          ;; sanity: this one IS reachable
(host-bl-test "seed-types: an instance of tag is, transitively, a type"
  (begin
    (host/blog-seed-types!)                            ;; type, tag, tag subtype-of type
    (host/blog-put! "ocaml" "OCaml" "(p \"lang\")" "published")
    (host/blog-relate! "ocaml" "tag" "is-a")           ;; ocaml is-a tag
    (list (host/blog-is-a? "ocaml" "tag") (host/blog-is-a? "ocaml" "type")))
  (list true true))
(host-bl-test "type-valid? is vacuously true with no schemas (gradual)"
  (host/blog-type-valid? "ppost" "(p \"anything\")") true)

;; -- relations-as-posts: declaration-driven candidate pools (plans/relations-as-posts.md) --
;; The picker's candidate set is the down-closure of a relation's anchors. is-a/subtype-of
;; are anchored by `type`, so they offer the WHOLE type closure — the roots (type/tag/
;; article) AND the instances — fixing the wrinkle where only instances showed.
(host-bl-test "is-a candidates = the type closure: roots (type/tag/article) AND instances"
  (let ((pool (host/blog--candidate-pool "is-a")))
    (list (contains? pool "type") (contains? pool "tag")
          (contains? pool "article") (contains? pool "ocaml")))   ;; ocaml is-a tag
  (list true true true true))
(host-bl-test "is-a candidates exclude a plain content post (not is-a/subtype-reachable to Type)"
  (contains? (host/blog--candidate-pool "is-a") "ppost") false)
(host-bl-test "tagged candidates are anchored by tag (tag + its instances)"
  (let ((pool (host/blog--candidate-pool "tagged")))
    (list (contains? pool "tag") (contains? pool "ocaml")))
  (list true true))
(host-bl-test "related candidates = every post (no declaration anchors it)"
  (let ((pool (host/blog--candidate-pool "related")))
    (list (contains? pool "type") (contains? pool "ppost")))
  (list true true))
;; and it flows through to the live picker endpoint: the is-a picker now offers a type root
(host-bl-test "is-a relate-options offers the type roots (Article)"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/ppost/relate-options?kind=is-a"))) "Article")
  true)

;; -- relations are posts: symmetry + labels read off the relation-posts (slice 2) --
(host-bl-test "kind-spec reads :rel metadata off the relation-post"
  (let ((s (host/blog--kind-spec "is-a")))
    (list (get s :kind) (get s :label) (get s :symmetric) (get s :inverse-label)))
  (list "is-a" "Types" false "Instances"))
(host-bl-test "kind-symmetric? reads symmetry off the post (related yes, is-a no)"
  (list (host/blog--kind-symmetric? "related") (host/blog--kind-symmetric? "is-a"))
  (list true false))
(host-bl-test "an unknown kind has no spec, so relate still validates it away"
  (host/blog--kind-spec "bogus-kind") nil)
(host-bl-test "rel-kinds is DERIVED from the graph (every post that is-a relation)"
  (let ((kinds (map (fn (s) (get s :kind)) host/blog-rel-kinds)))
    (list (contains? kinds "related") (contains? kinds "is-a")
          (contains? kinds "subtype-of") (contains? kinds "tagged")))
  (list true true true true))

;; -- relations are TYPED: the target-type constraint is enforced (slice 3) --
;; A valid object of a relation is one in its declared candidate set (the picker's
;; pool). So is-a's object must be a type, tagged's must be a tag, related's any post.
(host-bl-test "valid-object?: is-a accepts a type (article), rejects a plain post (ppost)"
  (list (host/blog--valid-object? "is-a" "article") (host/blog--valid-object? "is-a" "ppost"))
  (list true false))
(host-bl-test "valid-object?: tagged accepts a tag (ocaml); related accepts any post"
  (list (host/blog--valid-object? "tagged" "ocaml") (host/blog--valid-object? "related" "ppost"))
  (list true true))
;; the relate ENDPOINT enforces it: is-a to a type relates; is-a to a non-type no-ops.
(host/blog-unrelate! "alpha-post" "article" "is-a")
(host-bl-test "relate-submit: is-a to a type (article) creates the edge"
  (begin
    (host/blog-relate-submit (host-bl-relreq "alpha-post" "relate"
      {:sx-request "true" :sx-target "#rel-editor-is-a"} "article" "is-a"))
    (contains? (host/blog-out "alpha-post" "is-a") "article"))
  true)
(host/blog-unrelate! "alpha-post" "article" "is-a")
(host-bl-test "relate-submit: is-a to a NON-type (beta-post) is rejected (no edge)"
  (begin
    (host/blog-relate-submit (host-bl-relreq "alpha-post" "relate"
      {:sx-request "true" :sx-target "#rel-editor-is-a"} "beta-post" "is-a"))
    (contains? (host/blog-out "alpha-post" "is-a") "beta-post"))
  false)

;; -- Slice 4: type ALGEBRA — intersection (∧) and union (∨) types --
;; ocaml is-a tag (seeded above); make it is-a article too, so it's in BOTH extents.
(host/blog-relate! "ocaml" "article" "is-a")
(host/blog-make-and! "taggy-article" "tag" "article")   ;; tag ∧ article
(host/blog-make-or!  "tag-or-article" "tag" "article")   ;; tag ∨ article
(host-bl-test "intersection (∧): a member iff it's an instance of BOTH operands"
  (list (host/blog-is-a-expr? "ocaml" "taggy-article")   ;; is-a tag AND is-a article
        (host/blog-is-a-expr? "ppost" "taggy-article"))  ;; neither
  (list true false))
(host-bl-test "union (∨): a member iff it's an instance of EITHER operand"
  (list (host/blog-is-a-expr? "ocaml" "tag-or-article")  ;; is-a tag (and article)
        (host/blog-is-a-expr? "ppost" "tag-or-article"))  ;; neither tag nor article
  (list true false))
(host-bl-test "the extent is the set intersection of the operands' extents"
  (let ((ext (host/blog-instances-of-expr "taggy-article")))
    (list (contains? ext "ocaml")                         ;; in tag ∩ article
          (contains? ext "ppost")))                       ;; in neither
  (list true false))
;; algebra is META-CIRCULAR: an operand can itself be an algebraic type.
(host/blog-make-and! "nested-and" "taggy-article" "tag")  ;; (tag ∧ article) ∧ tag
(host-bl-test "nested type expression: (tag ∧ article) ∧ tag still admits ocaml"
  (host/blog-is-a-expr? "ocaml" "nested-and") true)

;; -- Slice 5: refinement types — schemas live ON the type-post --
;; article's schema (now on the article post) is still enforced for its instances.
(host/blog-put! "art-test" "Art Test" "(p \"x\")" "published")
(host/blog-relate! "art-test" "article" "is-a")
(host-bl-test "article (refinement type, schema on the post) requires an h1"
  (list (host/blog-type-valid? "art-test" "(p \"no heading\")")              ;; missing h1
        (host/blog-type-valid? "art-test" "(article (h1 \"H\") (p \"x\"))"))  ;; has h1
  (list false true))
;; a NEW refinement type is pure data: give a type-post a :schema and its instances
;; are validated against it — no code, no hardcoded table.
(host/blog-seed! "guide" "Guide" "(article (h1 \"Guide\") (p \"A guide.\"))" "published")
(host/blog-relate! "guide" "type" "subtype-of")
(host/blog--set-schema! "guide" (list {:block "pre" :msg "a guide needs a code block (pre)"}))
(host/blog-put! "g1" "G1" "(p \"x\")" "published")
(host/blog-relate! "g1" "guide" "is-a")
(host-bl-test "a NEW refinement type validates its instances against its :schema"
  (list (host/blog-type-valid? "g1" "(p \"no code\")")                       ;; missing pre
        (host/blog-type-valid? "g1" "(article (pre \"x\") (p \"y\"))"))      ;; has pre
  (list false true))
(host-bl-test "the schema is read off the type-post (data, not a hardcoded table)"
  (contains? (str (host/blog-schema-of "guide")) "code block") true)
;; editing a refinement type preserves its :schema (put! merges over the record).
(host/blog-put! "guide" "Guide v2" "(article (h1 \"Guide\") (p \"edited\"))" "published")
(host-bl-test "editing a type-post preserves its :schema (and metadata survives edits)"
  (contains? (str (host/blog-schema-of "guide")) "code block") true)

;; -- Phase 3: tags as posts --  (ocaml is-a tag, from the seed-types test above)
(host-bl-test "is-tag?: a post that is-a tag is a tag; others are not"
  (list (host/blog-is-tag? "ocaml") (host/blog-is-tag? "ppost"))
  (list true false))
(host-bl-test "instances-of tag includes the tag posts"
  (contains? (host/blog-instances-of "tag") "ocaml") true)
(host-bl-test "tag a post: it appears in tags + tagged-with (inverse)"
  (begin
    (host/blog-relate! "ppost" "ocaml" "tagged")        ;; ppost tagged ocaml
    (list (contains? (host/blog-tags "ppost") "ocaml")
          (contains? (host/blog-tagged-with "ocaml") "ppost")))
  (list true true))
(host-bl-test "tagged picker offers only tags (kind=tagged)"
  (let ((body (dream-resp-body (host-bl-app (host-bl-req "/particle/relate-options?kind=tagged")))))
    (list (contains? body ">OCaml<") (contains? body ">P Article<")))
  (list true false))
(host-bl-test "related picker still offers all posts (kind defaults to related)"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/particle/relate-options"))) ">P Doc<")
  true)
(host-bl-test "is-a-tag toggle marks a post a tag via /relate kind=is-a"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/pdoc/relate" "Bearer good"
      "application/x-www-form-urlencoded" "other=tag&kind=is-a"))
    (host/blog-is-tag? "pdoc"))
  true)

;; -- Phase 4: registry-driven render + /tags index --
(host-bl-test "relation-blocks renders Related + Tags from the registry"
  (begin
    (host/blog-relate! "hint-post" "ppost" "related")
    (host/blog-relate! "hint-post" "ocaml" "tagged")
    (let ((body (str (host/blog--relation-blocks "hint-post"))))
      (list (contains? body "Related posts") (contains? body "Tags"))))
  (list true true))
(host-bl-test "relation-blocks shows an inverse block (Tagged with this) for a tag"
  (contains? (str (host/blog--relation-blocks "ocaml")) "Tagged with this") true)
(host-bl-test "/tags lists the tag posts"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/tags"))) "OCaml") true)
(host-bl-test "/tags is 200 (not shadowed by /:slug)"
  (dream-status (host-bl-app (host-bl-req "/tags"))) 200)

;; -- Phase 6: gradual schema validation --
(host/blog-seed-types!)   ;; ensures the "article" type + its schema (requires h1)
(host-bl-test "all-tags finds nested element tags"
  (let ((tags (host/blog--all-tags (parse-safe "(article (h1 \"T\") (p \"x\"))"))))
    (list (contains? tags "h1") (contains? tags "p") (contains? tags "section")))
  (list true true false))
(host-bl-test "schema-issues: missing required block -> 1 issue; present -> 0"
  (let ((sch (host/blog-schema-of "article")))
    (list (len (host/blog--schema-issues sch "(p \"no heading\")"))
          (len (host/blog--schema-issues sch "(article (h1 \"yes\"))"))))
  (list 1 0))
(host-bl-test "type-valid? enforces an is-a article's schema"
  (begin
    (host/blog-put! "art1" "Art 1" "(p \"x\")" "published")
    (host/blog-relate! "art1" "article" "is-a")
    (list (host/blog-type-valid? "art1" "(p \"no heading\")")
          (host/blog-type-valid? "art1" "(article (h1 \"H\") (p \"x\"))")))
  (list false true))

;; -- metamodel overview (GET /meta) --
(host-bl-test "type-defs = the subtype hierarchy (type defs), not is-a instances"
  (let ((defs (host/blog-type-defs)))
    (list (contains? defs "type") (contains? defs "article") (contains? defs "art1")))
  (list true true false))
(host-bl-test "/meta is 200 (not shadowed by /:slug)"
  (dream-status (host-bl-app (host-bl-req "/meta"))) 200)
(host-bl-test "/meta lists type definitions + relations + the article's required block"
  (let ((body (dream-resp-body (host-bl-app (host-bl-req "/meta")))))
    (list (contains? body "Metamodel") (contains? body "Article")
          (contains? body "h1") (contains? body "related")
          (contains? body "symmetric")))
  (list true true true true true))

;; -- Slice 8: typed scalar fields on a type --
(host-bl-test "fields-of reads a type's declared fields (seeded on article)"
  (map (fn (f) (get f :name)) (host/blog-fields-of "article"))
  (list "subtitle" "hero"))
(host-bl-test "widget-for: explicit > value-type default > text fallback"
  (list (host/blog--widget-for {:name "a" :type "URL"})
        (host/blog--widget-for {:name "b" :type "Text"})
        (host/blog--widget-for {:name "c" :type "Nonsense"})
        (host/blog--widget-for {:name "d" :type "String" :widget "custom"}))
  (list "url" "textarea" "text" "custom"))
(host-bl-test "set-fields! is idempotent + preserves the rest of the record"
  (begin
    (host/blog--set-fields! "article"
      (list {:name "subtitle" :type "String"} {:name "hero" :type "URL"}))
    (list (get (host/blog-get "article") :title) (len (host/blog-fields-of "article"))))
  (list "Article" 2))
(host-bl-test "a type with no declared fields -> empty list"
  (host/blog-fields-of "tag") (list))
(host-bl-test "/meta shows the article's typed fields"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/meta"))) "subtitle:String") true)

;; -- Slice 8b: field values + the generic, type-driven edit form --
(host-bl-test "fields-for-post = union of the post's (transitive) types' fields"
  (begin
    (host/blog-put! "fpost" "F Post" "(article (h1 \"F\"))" "published")
    (host/blog-relate! "fpost" "article" "is-a")
    (map (fn (f) (get f :name)) (host/blog--fields-for-post "fpost")))
  (list "subtitle" "hero"))
(host-bl-test "a post of no typed type has no fields"
  (host/blog--fields-for-post "hello") (list))
(host-bl-test "set/get field-values round-trips on an instance"
  (begin
    (host/blog--set-field-values! "fpost" {"subtitle" "A subtitle" "hero" "http://x/y.png"})
    (list (get (host/blog-field-values-of "fpost") "subtitle")
          (get (host/blog-field-values-of "fpost") "hero")))
  (list "A subtitle" "http://x/y.png"))
(host-bl-test "edit form renders one input per field for a typed post"
  (let ((body (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/fpost/edit" "Bearer good" nil "")))))
    (list (contains? body "field-subtitle") (contains? body "field-hero") (contains? body "Fields")))
  (list true true true))
(host-bl-test "edit-submit stores the typed field values from the form"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/fpost/edit" "Bearer good"
      "application/x-www-form-urlencoded"
      "sx_content=(article+(h1+%22F%22))&field-subtitle=Saved+Sub&field-hero=http%3A%2F%2Fz%2Fq.png"))
    (list (get (host/blog-field-values-of "fpost") "subtitle")
          (get (host/blog-field-values-of "fpost") "hero")))
  (list "Saved Sub" "http://z/q.png"))

;; -- Slice 8c: render template per type (fields drive the page too) --
(host-bl-test "instantiate resolves (field name), replacing the placeholder"
  (list (contains? (str (host/blog--instantiate (parse-safe "(p (field \"subtitle\"))") {"subtitle" "Hi"})) "Hi")
        (contains? (str (host/blog--instantiate (parse-safe "(p (field \"x\"))") {})) "field"))
  (list true false))
(host-bl-test "template-of reads the article's seeded render template"
  (contains? (host/blog-template-of "article") "field") true)
(host-bl-test "typed-block renders a typed post's field values"
  (begin
    (host/blog--set-field-values! "fpost" {"subtitle" "My Standfirst" "hero" ""})
    (contains? (str (host/blog--typed-block "fpost")) "My Standfirst"))
  true)
(host-bl-test "typed-block is empty for an untyped post"
  (host/blog--typed-block "hello") "")
(host-bl-test "post page renders the typed template standfirst"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/fpost/"))) "My Standfirst") true)

;; -- metamodel editor: define a type through the UI (POST /meta/new-type) --
(host-bl-test "/meta has the create-type form"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/meta"))) "/meta/new-type") true)
(host-bl-test "POST /meta/new-type creates a type (subtype-of type) in type-defs"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/meta/new-type" "Bearer good"
      "application/x-www-form-urlencoded" "title=Recipe"))
    (list (host/blog-exists? "recipe") (contains? (host/blog-type-defs) "recipe")))
  (list true true))
(host-bl-test "create-type requires auth (unauthed -> not created)"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/meta/new-type" nil
      "application/x-www-form-urlencoded" "title=Sneaky Type"))
    (host/blog-exists? "sneaky-type"))
  false)

;; -- metamodel editor: define a relation through the UI (POST /meta/new-relation) --
(host-bl-test "/meta has the create-relation form"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/meta"))) "/meta/new-relation") true)
(host-bl-test "POST /meta/new-relation creates + registers a relation (session-scoped)"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/meta/new-relation" "Bearer good"
      "application/x-www-form-urlencoded" "title=Blocks&label=Blocks&symmetric=on"))
    (list (host/blog-exists? "blocks")
          (host/blog-is-a? "blocks" "relation")
          (not (nil? (host/blog--kind-spec "blocks")))
          (host/blog--kind-symmetric? "blocks")))
  (list true true true true))
(host-bl-test "create-relation requires auth (unauthed -> not created)"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/meta/new-relation" nil
      "application/x-www-form-urlencoded" "title=Sneaky Rel"))
    (host/blog-exists? "sneaky-rel"))
  false)

;; -- cards-as-types: the blog content block vocabulary --
(host-bl-test "card-types are seeded as subtypes of card (in type-defs)"
  (let ((defs (host/blog-type-defs)))
    (list (contains? defs "card") (contains? defs "card-image") (contains? defs "card-heading")))
  (list true true true))
(host-bl-test "a card-type carries its fields"
  (map (fn (f) (get f :name)) (host/blog-fields-of "card-image"))
  (list "src" "alt" "caption"))
(host-bl-test "/meta lists the card vocabulary with fields"
  (let ((body (dream-resp-body (host-bl-app (host-bl-req "/meta")))))
    (list (contains? body ">Image</a>") (contains? body "src:URL, alt:String")))
  (list true true))

;; -- typed Ghost import (the radar genesis-import seam) --
(host-bl-test "import-post! lands a Ghost post as a typed Article + fields + tags"
  (begin
    (host/blog-import-post! {"slug" "g1" "title" "G1" "sx_content" "(article (h1 \"G1\"))"
                             "status" "published" "custom_excerpt" "A standfirst"
                             "feature_image" "http://i/h.jpg" "tags" (list "News")})
    (list (host/blog-is-a? "g1" "article")
          (get (host/blog-field-values-of "g1") "subtitle")
          (get (host/blog-field-values-of "g1") "hero")
          (contains? (host/blog-out "g1" "tagged") "news")))
  (list true "A standfirst" "http://i/h.jpg" true))
(host-bl-test "POST /import (text/sx list of Ghost dicts) lands typed posts"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/import" "Bearer good" "text/sx"
      "({:slug \"g2\" :title \"G2\" :sx_content \"(p \\\"b\\\")\" :status \"published\" :custom_excerpt \"S2\"})"))
    (list (host/blog-is-a? "g2" "article") (get (host/blog-field-values-of "g2") "subtitle")))
  (list true "S2"))
(host-bl-test "POST /import rejects a non-list body -> 400"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/import" "Bearer good" "text/sx" "{:x 1}")))
  400)

;; -- composition objects: a record with :body renders via the render-fold --
(host-bl-test "a record's :body renders via the fold, different per context"
  (begin
    (host/blog-put! "cdoc" "C" "(p \"fallback\")" "published")
    (host/blog--set-body! "cdoc"
      (quote (seq (alt (when (has "auth") (text "MEMBER")) (else (text "ANON")))
                  (each (items {:name "X"} {:name "Y"}) (field :name)))))
    (list (host/comp-render (host/blog-body-of "cdoc") {})
          (host/comp-render (host/blog-body-of "cdoc") {"auth" "y"})))
  (list "ANON<span>X</span><span>Y</span>" "MEMBER<span>X</span><span>Y</span>"))
(host-bl-test "post page renders :body (composition) over sx_content"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/cdoc/"))) "ANON") true)
;; -- the each source can be a GRAPH QUERY: the list isn't baked into the body, it's
;; whatever is-a the type right now (data-driven). The resolver (host/blog--comp-query)
;; is injected into the render context by host/blog--comp-ctx. --
(host-bl-test "each(query is-a TYPE) iterates real graph instances"
  (begin
    (host/blog-seed! "qtype" "QType" "(p \"t\")" "published")
    (host/blog-relate! "qtype" "type" "subtype-of")
    (host/blog-seed! "qi-1" "Item One" "(p \"1\")" "published")
    (host/blog-seed! "qi-2" "Item Two" "(p \"2\")" "published")
    (host/blog-relate! "qi-1" "qtype" "is-a")
    (host/blog-relate! "qi-2" "qtype" "is-a")
    (let ((out (host/comp-render
                 (quote (each (query is-a qtype)
                          (seq (text "<a href=\"/") (val :slug) (text "\">") (field :title) (text "</a>"))))
                 (host/blog--comp-ctx nil nil))))
      ;; field wraps in <span> (display); val is raw (for the href attribute).
      (list (contains? out "Item One") (contains? out "Item Two")
            (contains? out "/qi-1") (contains? out "<span>Item One</span>"))))
  (list true true true true))
;; a query with no matching instances renders empty (not an error) — robustness.
(host-bl-test "each(query is-a TYPE) with no instances renders empty"
  (host/comp-render
    (quote (each (query is-a no-such-type) (field :title)))
    (host/blog--comp-ctx nil nil))
  "")
;; -- live context: the SAME object renders a responsive variant per request (device from
;; the User-Agent, locale from Accept-Language) — context is the execution environment. --
(host-bl-test "comp-ctx reads device + locale from the request headers"
  (let ((mob (host/blog--comp-ctx nil (dream-request "GET" "/x" {"user-agent" "X iPhone Y" "accept-language" "fr-FR,fr"} "")))
        (desk (host/blog--comp-ctx nil (dream-request "GET" "/x" {"user-agent" "Mozilla Linux" "accept-language" "en-US"} ""))))
    (list (get mob "device") (get mob "locale") (get desk "device") (get desk "locale")))
  (list "mobile" "fr" "desktop" "en"))
(host-bl-test "one object renders a device-specific variant via (alt (when (eq device …)))"
  (let ((body (quote (alt (when (eq "device" "mobile") (text "M")) (else (text "D")))))
        (mob (dream-request "GET" "/x" {"user-agent" "iPhone"} ""))
        (desk (dream-request "GET" "/x" {"user-agent" "Linux"} "")))
    (list (host/comp-render body (host/blog--comp-ctx nil mob))
          (host/comp-render body (host/blog--comp-ctx nil desk))))
  (list "M" "D"))
;; -- cards-as-objects: the importer decomposes content into card OBJECTS + a contains body
;; (not one opaque sx_content string). Each top-level block becomes a stored card object
;; (is-a a card-type + field-values), linked by ordered `contains` edges; the post :body is
;; (seq (ref c0) (ref c1) …) and the render-fold transcludes each card via its type template. --
(host-bl-test "import decomposes the body into typed card objects + a contains body"
  (begin
    (host/blog-import-post! {"slug" "imp-x" "title" "Imp X"
      "sx_content" "(article (h1 \"Heading One\") (p \"Para text.\") (img :src \"p.jpg\" :alt \"alt\"))"
      "status" "published"})
    (list (len (host/blog-out "imp-x" "contains"))
          (host/blog-is-a? "imp-x__b0" "card-heading")
          (host/blog-is-a? "imp-x__b1" "card-text")
          (host/blog-is-a? "imp-x__b2" "card-image")
          (get (host/blog-field-values-of "imp-x__b0") "text")
          (get (host/blog-field-values-of "imp-x__b2") "src")))
  (list 3 true true true "Heading One" "p.jpg"))
(host-bl-test "a decomposed post :body is a (seq (ref …) …) composition"
  (let ((body (host/blog-body-of "imp-x")))
    (list (str (first body)) (len (rest body)) (str (first (first (rest body))))))
  (list "seq" 3 "ref"))
;; the card objects are status "block" — stored but NOT listed as top-level posts.
(host-bl-test "decomposed card objects do not appear on the published home index"
  (contains? (dream-resp-body (host-bl-app (host-bl-req "/"))) "imp-x__b0") false)
;; the post page renders the cards by TRANSCLUSION (ref -> card-type template).
(host-bl-test "decomposed post page renders the transcluded cards"
  (let ((html (dream-resp-body (host-bl-app (host-bl-req "/imp-x/")))))
    (list (contains? html "Heading One") (contains? html "Para text.") (contains? html "p.jpg")))
  (list true true true))
;; -- block editor: structural edits to the post :body composition (step 6). --
(host-bl-test "block-add! creates a card object + contains edge + appends a ref to the body"
  (begin
    (host/blog-put! "bdoc" "BDoc" "(article)" "published")
    (host/blog--set-body! "bdoc" (quote (seq)))
    (let ((c0 (host/blog-block-add! "bdoc" "card-text" {"text" "first"}))
          (c1 (host/blog-block-add! "bdoc" "card-heading" {"level" "2" "text" "a head"})))
      (list (host/blog-body-refs "bdoc")
            (host/blog-is-a? c0 "card-text")
            (contains? (host/blog-out "bdoc" "contains") c1))))
  (list (list "bdoc__b0" "bdoc__b1") true true))
(host-bl-test "block-move! reorders the body refs (and is a no-op at the ends)"
  (begin
    (host/blog-block-move! "bdoc" "bdoc__b1" "up")          ;; b1 before b0
    (let ((after-up (host/blog-body-refs "bdoc")))
      (host/blog-block-move! "bdoc" "bdoc__b1" "up")        ;; b1 already first -> no-op
      (list after-up (host/blog-body-refs "bdoc"))))
  (list (list "bdoc__b1" "bdoc__b0") (list "bdoc__b1" "bdoc__b0")))
(host-bl-test "block-remove! drops the ref from the body + the contains edge"
  (begin
    (host/blog-block-remove! "bdoc" "bdoc__b1")
    (list (host/blog-body-refs "bdoc") (contains? (host/blog-out "bdoc" "contains") "bdoc__b1")))
  (list (list "bdoc__b0") false))
(host-bl-test "the edit page shows the block editor (#block-editor + an add-block form)"
  (let ((html (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/bdoc/edit" "Bearer good" "" "")))))
    (list (contains? html "block-editor") (contains? html "+ add block")))
  (list true true))
(host-bl-test "POST /bdoc/blocks/add (auth) adds a block -> body grows"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/bdoc/blocks/add" "Bearer good"
      "application/x-www-form-urlencoded" "ctype=card-text&text=added+block"))
    (len (host/blog-body-refs "bdoc")))
  2)
(host-bl-test "a post with no schema'd type is vacuously valid"
  (host/blog-type-valid? "ppost" "(p \"anything\")") true)
(host-bl-test "edit-submit rejects content violating the type schema (not saved)"
  (begin
    (host-bl-wapp (host-bl-send "POST" "/art1/edit" "Bearer good"
      "application/x-www-form-urlencoded" "sx_content=(p+%22still+no+heading%22)"))
    (contains? (dream-resp-body (host-bl-wapp (host-bl-req "/art1/"))) "still no heading"))
  false)
(host-bl-test "edit-submit accepts content satisfying the schema -> 303"
  (dream-status (host-bl-wapp (host-bl-send "POST" "/art1/edit" "Bearer good"
    "application/x-www-form-urlencoded" "sx_content=(article+(h1+%22Heading%22)+(p+%22body%22))")))
  303)

;; -- experimental unguarded create-only route (POST /new, no auth) --
(define host-bl-oapp (host/make-app (list host/blog-open-create-routes host/blog-routes)))
(host/blog-use-store! (persist/open))
(host-bl-test "open create no auth -> 303"
  (dream-status (host-bl-oapp (host-bl-send "POST" "/new" nil
    "application/x-www-form-urlencoded" "title=Open+Post&sx_content=(p+%22o%22)&status=published")))
  303)
(host-bl-test "open-created post renders"
  (contains? (dream-resp-body (host-bl-oapp (host-bl-req "/open-post/"))) "<p>o</p>")
  true)

;; ── content-addressing: every object carries a stable CID ───────────
;; A CID is the hash of the canonical (key-sorted) content; the slug (a name) and
;; any prior :cid are excluded. Same content -> same CID, across slugs and processes.
(host/blog-use-store! (persist/open))
(host/blog-put! "cid-a" "Same Body" "(p \"same\")" "published")
(host/blog-put! "cid-b" "Same Body" "(p \"same\")" "published")
(host-bl-test "put! stamps a non-nil CID"
  (and (not (nil? (host/blog-cid "cid-a"))) (> (len (host/blog-cid "cid-a")) 1)) true)
(host-bl-test "content-addressed: identical content -> identical CID (slug excluded)"
  (= (host/blog-cid "cid-a") (host/blog-cid "cid-b")) true)
(host-bl-test "CID changes when content changes"
  (let ((before (host/blog-cid "cid-a")))
    (host/blog-put! "cid-a" "Same Body" "(p \"different now\")" "published")
    (not (= before (host/blog-cid "cid-a"))))
  true)
(host-bl-test "canon excludes :slug and :cid"
  (= (host/blog--canon {:slug "x" :cid "old" :title "T"})
     (host/blog--canon {:title "T"}))
  true)
(host-bl-test "by-cid reverse lookup finds a slug with that CID"
  (not (nil? (host/blog-by-cid (host/blog-cid "cid-b")))) true)
(host-bl-test "by-cid of an unknown CID is nil"
  (host/blog-by-cid "znope-nope") nil)

(define
  host-bl-tests-run!
  (fn ()
    {:total (+ host-bl-pass host-bl-fail)
     :passed host-bl-pass :failed host-bl-fail :fails host-bl-fails}))
