Rebrand sexp → sx across web platform (173 files)

Rename all sexp directories, files, identifiers, and references to sx.
artdag/ excluded (separate media processing DSL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 11:06:57 +00:00
parent 17cebe07e7
commit e8bc228c7f
174 changed files with 3126 additions and 2952 deletions

View File

View File

@@ -0,0 +1,350 @@
"""Tests for shared s-expression components (Phase 5)."""
import pytest
from shared.sx.jinja_bridge import sx, _COMPONENT_ENV
from shared.sx.components import load_shared_components
@pytest.fixture(autouse=True)
def _load_components():
"""Ensure all shared components are registered for every test."""
_COMPONENT_ENV.clear()
load_shared_components()
# ---------------------------------------------------------------------------
# ~cart-mini
# ---------------------------------------------------------------------------
class TestCartMini:
def test_empty_cart_shows_logo(self):
html = sx(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
**{"cart-count": 0, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
)
assert 'id="cart-mini"' in html
assert "logo.jpg" in html
assert "blog.example.com/" in html
assert "fa-shopping-cart" not in html
def test_nonempty_cart_shows_badge(self):
html = sx(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
**{"cart-count": 3, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
)
assert 'id="cart-mini"' in html
assert "fa-shopping-cart" in html
assert "bg-emerald-600" in html
assert ">3<" in html
assert "cart.example.com/" in html
def test_oob_attribute(self):
html = sx(
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
)
assert 'sx-swap-oob="true"' in html
def test_no_oob_when_nil(self):
html = sx(
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "")',
)
assert "sx-swap-oob" not in html
# ---------------------------------------------------------------------------
# ~auth-menu
# ---------------------------------------------------------------------------
class TestAuthMenu:
def test_logged_in(self):
html = sx(
'(~auth-menu :user-email user-email :account-url account-url)',
**{"user-email": "alice@example.com", "account-url": "https://account.example.com/"},
)
assert 'id="auth-menu-desktop"' in html
assert 'id="auth-menu-mobile"' in html
assert "alice@example.com" in html
assert "fa-solid fa-user" in html
assert "sign in or register" not in html
def test_logged_out(self):
html = sx(
'(~auth-menu :account-url account-url)',
**{"account-url": "https://account.example.com/"},
)
assert "fa-solid fa-key" in html
assert "sign in or register" in html
def test_desktop_has_data_close_details(self):
html = sx(
'(~auth-menu :user-email "x@y.com" :account-url "http://a")',
)
assert "data-close-details" in html
def test_two_spans_always_present(self):
"""Both desktop and mobile spans are always rendered."""
for email in ["user@test.com", None]:
html = sx(
'(~auth-menu :user-email user-email :account-url account-url)',
**{"user-email": email, "account-url": "http://a"},
)
assert 'id="auth-menu-desktop"' in html
assert 'id="auth-menu-mobile"' in html
# ---------------------------------------------------------------------------
# ~account-nav-item
# ---------------------------------------------------------------------------
class TestAccountNavItem:
def test_renders_link(self):
html = sx(
'(~account-nav-item :href "/orders/" :label "orders")',
)
assert 'href="/orders/"' in html
assert ">orders<" in html
assert "nav-group" in html
assert "sx-disable" in html
def test_custom_label(self):
html = sx(
'(~account-nav-item :href "/cart/orders/" :label "my orders")',
)
assert ">my orders<" in html
# ---------------------------------------------------------------------------
# ~calendar-entry-nav
# ---------------------------------------------------------------------------
class TestCalendarEntryNav:
def test_renders_entry(self):
html = sx(
'(~calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
**{"date-str": "Jan 15, 2026 at 14:00", "nav-class": "btn"},
)
assert 'href="/events/entry/1/"' in html
assert "Workshop" in html
assert "Jan 15, 2026 at 14:00" in html
# ---------------------------------------------------------------------------
# ~calendar-link-nav
# ---------------------------------------------------------------------------
class TestCalendarLinkNav:
def test_renders_calendar_link(self):
html = sx(
'(~calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
**{"nav-class": "btn"},
)
assert 'href="/events/cal/"' in html
assert "fa fa-calendar" in html
assert "Art Events" in html
# ---------------------------------------------------------------------------
# ~market-link-nav
# ---------------------------------------------------------------------------
class TestMarketLinkNav:
def test_renders_market_link(self):
html = sx(
'(~market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
**{"nav-class": "btn"},
)
assert 'href="/market/farm/"' in html
assert "fa fa-shopping-bag" in html
assert "Farm Shop" in html
# ---------------------------------------------------------------------------
# ~post-card
# ---------------------------------------------------------------------------
class TestPostCard:
def test_basic_card(self):
html = sx(
'(~post-card :title "Hello World" :slug "hello" :href "/hello/"'
' :feature-image "/img/hello.jpg" :excerpt "A test post"'
' :status "published" :published-at "15 Jan 2026"'
' :hx-select "#main-panel")',
**{
"feature-image": "/img/hello.jpg",
"hx-select": "#main-panel",
"published-at": "15 Jan 2026",
},
)
assert "<article" in html
assert "Hello World" in html
assert 'href="/hello/"' in html
assert '<img src="/img/hello.jpg"' in html
assert "A test post" in html
def test_draft_status(self):
html = sx(
'(~post-card :title "Draft" :slug "draft" :href "/draft/"'
' :status "draft" :updated-at "15 Jan 2026"'
' :hx-select "#main-panel")',
**{"hx-select": "#main-panel", "updated-at": "15 Jan 2026"},
)
assert "Draft" in html
assert "bg-amber-100" in html
assert "Updated:" in html
def test_draft_with_publish_requested(self):
html = sx(
'(~post-card :title "Pending" :slug "pending" :href "/pending/"'
' :status "draft" :publish-requested true'
' :hx-select "#main-panel")',
**{"hx-select": "#main-panel", "publish-requested": True},
)
assert "Publish requested" in html
assert "bg-blue-100" in html
def test_no_image(self):
html = sx(
'(~post-card :title "No Img" :slug "no-img" :href "/no-img/"'
' :status "published" :hx-select "#main-panel")',
**{"hx-select": "#main-panel"},
)
assert "<img" not in html
def test_widgets_and_at_bar(self):
"""Widgets and at-bar are sx kwarg slots rendered by the client."""
html = sx(
'(~post-card :title "T" :slug "s" :href "/"'
' :status "published" :hx-select "#mp")',
**{"hx-select": "#mp"},
)
# Basic render without widgets/at-bar should still work
assert "<article" in html
assert "T" in html
# ---------------------------------------------------------------------------
# ~base-shell and ~error-page
# ---------------------------------------------------------------------------
class TestBaseShell:
def test_renders_full_page(self):
html = sx(
'(~base-shell :title "Test" :asset-url "/static" (p "Hello"))',
**{"asset-url": "/static"},
)
assert "<!doctype html>" in html
assert "<html" in html
assert "<title>Test</title>" in html
assert "<p>Hello</p>" in html
assert "tailwindcss" in html
class TestErrorPage:
def test_404_page(self):
html = sx(
'(~error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
**{"asset-url": "/static"},
)
assert "<!doctype html>" in html
assert "NOT FOUND" in html
assert "text-red-500" in html
assert "/static/errors/404.gif" in html
def test_error_page_no_image(self):
html = sx(
'(~error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
**{"asset-url": "/static"},
)
assert "SERVER ERROR" in html
assert "<img" not in html
# ---------------------------------------------------------------------------
# ~relation-nav
# ---------------------------------------------------------------------------
class TestRelationNav:
def test_renders_link(self):
html = sx(
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
)
assert 'href="/market/farm/"' in html
assert "Farm Shop" in html
assert "fa fa-shopping-bag" in html
def test_no_icon(self):
html = sx(
'(~relation-nav :href "/cal/" :name "Events")',
)
assert 'href="/cal/"' in html
assert "Events" in html
assert "fa " not in html
def test_custom_nav_class(self):
html = sx(
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
**{"nav-class": "custom-class"},
)
assert 'class="custom-class"' in html
# ---------------------------------------------------------------------------
# ~relation-attach
# ---------------------------------------------------------------------------
class TestRelationAttach:
def test_renders_button(self):
html = sx(
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
**{"create-url": "/market/create/"},
)
assert 'href="/market/create/"' in html
assert 'sx-get="/market/create/"' in html
assert "Add Market" in html
assert "fa fa-plus" in html
def test_default_label(self):
html = sx(
'(~relation-attach :create-url "/create/")',
**{"create-url": "/create/"},
)
assert "Add" in html
# ---------------------------------------------------------------------------
# ~relation-detach
# ---------------------------------------------------------------------------
class TestRelationDetach:
def test_renders_button(self):
html = sx(
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
**{"detach-url": "/api/unrelate"},
)
assert 'sx-delete="/api/unrelate"' in html
assert 'sx-confirm="Remove Farm Shop?"' in html
assert "fa fa-times" in html
def test_default_name(self):
html = sx(
'(~relation-detach :detach-url "/api/unrelate")',
**{"detach-url": "/api/unrelate"},
)
assert "this item" in html
# ---------------------------------------------------------------------------
# render_page() helper
# ---------------------------------------------------------------------------
class TestRenderPage:
def test_render_page(self):
from shared.sx.page import render_page
html = render_page(
'(~error-page :title "Test" :message "MSG" :asset-url "/s")',
**{"asset-url": "/s"},
)
assert "<!doctype html>" in html
assert "MSG" in html

View File

@@ -0,0 +1,326 @@
"""Tests for the s-expression evaluator."""
import pytest
from shared.sx import parse, evaluate, EvalError, Symbol, Keyword, NIL
from shared.sx.types import Lambda, Component
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def ev(text, env=None):
"""Parse and evaluate a single expression."""
return evaluate(parse(text), env)
# ---------------------------------------------------------------------------
# Literals and lookups
# ---------------------------------------------------------------------------
class TestLiterals:
def test_int(self):
assert ev("42") == 42
def test_string(self):
assert ev('"hello"') == "hello"
def test_true(self):
assert ev("true") is True
def test_nil(self):
assert ev("nil") is NIL
def test_symbol_lookup(self):
assert ev("x", {"x": 10}) == 10
def test_undefined_symbol(self):
with pytest.raises(EvalError, match="Undefined symbol"):
ev("xyz")
def test_keyword_evaluates_to_name(self):
assert ev(":foo") == "foo"
# ---------------------------------------------------------------------------
# Arithmetic
# ---------------------------------------------------------------------------
class TestArithmetic:
def test_add(self):
assert ev("(+ 1 2 3)") == 6
def test_sub(self):
assert ev("(- 10 3)") == 7
def test_negate(self):
assert ev("(- 5)") == -5
def test_mul(self):
assert ev("(* 2 3 4)") == 24
def test_div(self):
assert ev("(/ 10 4)") == 2.5
def test_mod(self):
assert ev("(mod 7 3)") == 1
def test_clamp(self):
assert ev("(clamp 15 0 10)") == 10
assert ev("(clamp -5 0 10)") == 0
assert ev("(clamp 5 0 10)") == 5
# ---------------------------------------------------------------------------
# Comparison and predicates
# ---------------------------------------------------------------------------
class TestComparison:
def test_eq(self):
assert ev("(= 1 1)") is True
assert ev("(= 1 2)") is False
def test_lt_gt(self):
assert ev("(< 1 2)") is True
assert ev("(> 2 1)") is True
def test_predicates(self):
assert ev("(odd? 3)") is True
assert ev("(even? 4)") is True
assert ev("(zero? 0)") is True
assert ev("(nil? nil)") is True
assert ev('(string? "hi")') is True
assert ev("(number? 42)") is True
assert ev("(list? (list 1))") is True
assert ev("(dict? {:a 1})") is True
# ---------------------------------------------------------------------------
# Special forms
# ---------------------------------------------------------------------------
class TestSpecialForms:
def test_if_true(self):
assert ev("(if true 1 2)") == 1
def test_if_false(self):
assert ev("(if false 1 2)") == 2
def test_if_no_else(self):
assert ev("(if false 1)") is NIL
def test_when_true(self):
assert ev("(when true 42)") == 42
def test_when_false(self):
assert ev("(when false 42)") is NIL
def test_and_short_circuit(self):
assert ev("(and true true 3)") == 3
assert ev("(and true false 3)") is False
def test_or_short_circuit(self):
assert ev("(or false false 3)") == 3
assert ev("(or false 2 3)") == 2
def test_let_scheme_style(self):
assert ev("(let ((x 10) (y 20)) (+ x y))") == 30
def test_let_clojure_style(self):
assert ev("(let (x 10 y 20) (+ x y))") == 30
def test_let_sequential(self):
assert ev("(let ((x 1) (y (+ x 1))) y)") == 2
def test_begin(self):
assert ev("(begin 1 2 3)") == 3
def test_quote(self):
result = ev("(quote (a b c))")
assert result == [Symbol("a"), Symbol("b"), Symbol("c")]
def test_cond_clojure(self):
assert ev("(cond false 1 true 2 :else 3)") == 2
def test_cond_else(self):
assert ev("(cond false 1 false 2 :else 99)") == 99
def test_case(self):
assert ev('(case 2 1 "one" 2 "two" :else "other")') == "two"
def test_thread_first(self):
assert ev("(-> 5 (+ 3) (* 2))") == 16
def test_define(self):
env = {}
ev("(define x 42)", env)
assert env["x"] == 42
# ---------------------------------------------------------------------------
# Lambda
# ---------------------------------------------------------------------------
class TestLambda:
def test_create_and_call(self):
assert ev("((fn (x) (* x x)) 5)") == 25
def test_closure(self):
result = ev("(let ((a 10)) ((fn (x) (+ x a)) 5))")
assert result == 15
def test_higher_order(self):
result = ev("(let ((double (fn (x) (* x 2)))) (double 7))")
assert result == 14
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
class TestCollections:
def test_list_constructor(self):
assert ev("(list 1 2 3)") == [1, 2, 3]
def test_dict_constructor(self):
assert ev("(dict :a 1 :b 2)") == {"a": 1, "b": 2}
def test_get_dict(self):
assert ev('(get {:a 1 :b 2} "a")') == 1
def test_get_list(self):
assert ev("(get (list 10 20 30) 1)") == 20
def test_first_last_rest(self):
assert ev("(first (list 1 2 3))") == 1
assert ev("(last (list 1 2 3))") == 3
assert ev("(rest (list 1 2 3))") == [2, 3]
def test_len(self):
assert ev("(len (list 1 2 3))") == 3
def test_concat(self):
assert ev("(concat (list 1 2) (list 3 4))") == [1, 2, 3, 4]
def test_cons(self):
assert ev("(cons 0 (list 1 2))") == [0, 1, 2]
def test_keys_vals(self):
assert ev("(keys {:a 1 :b 2})") == ["a", "b"]
assert ev("(vals {:a 1 :b 2})") == [1, 2]
def test_merge(self):
assert ev("(merge {:a 1} {:b 2} {:a 3})") == {"a": 3, "b": 2}
def test_assoc(self):
assert ev('(assoc {:a 1} :b 2)') == {"a": 1, "b": 2}
def test_dissoc(self):
assert ev('(dissoc {:a 1 :b 2} :a)') == {"b": 2}
def test_empty(self):
assert ev("(empty? (list))") is True
assert ev("(empty? (list 1))") is False
assert ev("(empty? nil)") is True
def test_contains(self):
assert ev('(contains? {:a 1} "a")') is True
assert ev("(contains? (list 1 2 3) 2)") is True
# ---------------------------------------------------------------------------
# Higher-order forms
# ---------------------------------------------------------------------------
class TestHigherOrder:
def test_map(self):
assert ev("(map (fn (x) (* x x)) (list 1 2 3 4))") == [1, 4, 9, 16]
def test_map_indexed(self):
result = ev("(map-indexed (fn (i x) (+ i x)) (list 10 20 30))")
assert result == [10, 21, 32]
def test_filter(self):
assert ev("(filter (fn (x) (> x 2)) (list 1 2 3 4))") == [3, 4]
def test_reduce(self):
assert ev("(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))") == 6
def test_some(self):
assert ev("(some (fn (x) (if (> x 3) x nil)) (list 1 2 4 5))") == 4
def test_every(self):
assert ev("(every? (fn (x) (> x 0)) (list 1 2 3))") is True
assert ev("(every? (fn (x) (> x 2)) (list 1 2 3))") is False
# ---------------------------------------------------------------------------
# Strings
# ---------------------------------------------------------------------------
class TestStrings:
def test_str(self):
assert ev('(str "hello" " " "world")') == "hello world"
def test_str_numbers(self):
assert ev('(str "val=" 42)') == "val=42"
def test_upper_lower(self):
assert ev('(upper "hello")') == "HELLO"
assert ev('(lower "HELLO")') == "hello"
def test_join(self):
assert ev('(join ", " (list "a" "b" "c"))') == "a, b, c"
def test_split(self):
assert ev('(split "a,b,c" ",")') == ["a", "b", "c"]
# ---------------------------------------------------------------------------
# defcomp
# ---------------------------------------------------------------------------
class TestDefcomp:
def test_basic_component(self):
env = {}
ev("(defcomp ~card (&key title) title)", env)
assert isinstance(env["~card"], Component)
assert env["~card"].name == "card"
def test_component_call(self):
env = {}
ev("(defcomp ~greeting (&key name) (str \"Hello, \" name \"!\"))", env)
result = ev('(~greeting :name "Alice")', env)
assert result == "Hello, Alice!"
def test_component_with_children(self):
env = {}
ev("(defcomp ~wrapper (&key class &rest children) (list class children))", env)
result = ev('(~wrapper :class "box" 1 2 3)', env)
assert result == ["box", [1, 2, 3]]
def test_component_missing_kwarg_is_nil(self):
env = {}
ev("(defcomp ~opt (&key x y) (list x y))", env)
result = ev("(~opt :x 1)", env)
assert result == [1, NIL]
# ---------------------------------------------------------------------------
# Dict literal evaluation
# ---------------------------------------------------------------------------
class TestDictLiteral:
def test_dict_values_evaluated(self):
assert ev("{:a (+ 1 2) :b (* 3 4)}") == {"a": 3, "b": 12}
# ---------------------------------------------------------------------------
# set!
# ---------------------------------------------------------------------------
class TestSetBang:
def test_set_bang(self):
env = {"x": 1}
ev("(set! x 42)", env)
assert env["x"] == 42

View File

@@ -0,0 +1,365 @@
"""Tests for the HSX-style HTML renderer."""
import pytest
from shared.sx import parse, evaluate
from shared.sx.html import render, escape_text, escape_attr
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def r(text, env=None):
"""Parse and render a single expression."""
return render(parse(text), env)
# ---------------------------------------------------------------------------
# Escaping
# ---------------------------------------------------------------------------
class TestEscaping:
def test_escape_text_ampersand(self):
assert escape_text("A & B") == "A &amp; B"
def test_escape_text_lt_gt(self):
assert escape_text("<script>") == "&lt;script&gt;"
def test_escape_attr_quotes(self):
assert escape_attr('he said "hi"') == "he said &quot;hi&quot;"
def test_escape_attr_all(self):
assert escape_attr('&<>"') == "&amp;&lt;&gt;&quot;"
# ---------------------------------------------------------------------------
# Primitives / atoms
# ---------------------------------------------------------------------------
class TestAtoms:
def test_string(self):
assert r('"Hello"') == "Hello"
def test_string_with_entities(self):
assert r('"<b>bold</b>"') == "&lt;b&gt;bold&lt;/b&gt;"
def test_integer(self):
assert r("42") == "42"
def test_float(self):
assert r("3.14") == "3.14"
def test_nil(self):
assert r("nil") == ""
def test_true(self):
assert r("true") == ""
def test_false(self):
assert r("false") == ""
def test_keyword(self):
assert r(":hello") == "hello"
# ---------------------------------------------------------------------------
# Simple HTML elements
# ---------------------------------------------------------------------------
class TestElements:
def test_div(self):
assert r('(div "Hello")') == "<div>Hello</div>"
def test_empty_div(self):
assert r("(div)") == "<div></div>"
def test_nested(self):
assert r('(div (p "Hello"))') == "<div><p>Hello</p></div>"
def test_multiple_children(self):
assert r('(ul (li "One") (li "Two"))') == \
"<ul><li>One</li><li>Two</li></ul>"
def test_mixed_text_and_elements(self):
assert r('(p "Hello " (strong "world"))') == \
"<p>Hello <strong>world</strong></p>"
def test_deep_nesting(self):
assert r('(div (div (div (span "deep"))))') == \
"<div><div><div><span>deep</span></div></div></div>"
# ---------------------------------------------------------------------------
# Attributes
# ---------------------------------------------------------------------------
class TestAttributes:
def test_single_attr(self):
assert r('(div :class "card")') == '<div class="card"></div>'
def test_multiple_attrs(self):
html = r('(div :class "card" :id "main")')
assert 'class="card"' in html
assert 'id="main"' in html
def test_attr_with_children(self):
assert r('(div :class "card" (p "Body"))') == \
'<div class="card"><p>Body</p></div>'
def test_attr_escaping(self):
assert r('(div :title "A & B")') == '<div title="A &amp; B"></div>'
def test_attr_with_quotes(self):
assert r("""(div :title "say \\"hi\\"")""") == \
'<div title="say &quot;hi&quot;"></div>'
def test_false_attr_omitted(self):
env = {"flag": False}
html = render(parse("(div :hidden flag)"), env)
assert html == "<div></div>"
def test_nil_attr_omitted(self):
env = {"flag": None}
html = render(parse("(div :data-x flag)"), env)
assert html == "<div></div>"
def test_numeric_attr(self):
assert r('(input :tabindex 1)') == '<input tabindex="1">'
# ---------------------------------------------------------------------------
# Boolean attributes
# ---------------------------------------------------------------------------
class TestBooleanAttrs:
def test_disabled_true(self):
env = {"yes": True}
html = render(parse("(button :disabled yes)"), env)
assert html == "<button disabled></button>"
def test_disabled_false(self):
env = {"no": False}
html = render(parse("(button :disabled no)"), env)
assert html == "<button></button>"
def test_checked(self):
env = {"c": True}
html = render(parse('(input :type "checkbox" :checked c)'), env)
assert 'checked' in html
assert 'type="checkbox"' in html
def test_required(self):
env = {"r": True}
html = render(parse("(input :required r)"), env)
assert "required" in html
# ---------------------------------------------------------------------------
# Void elements
# ---------------------------------------------------------------------------
class TestVoidElements:
def test_br(self):
assert r("(br)") == "<br>"
def test_img(self):
html = r('(img :src "/photo.jpg" :alt "Photo")')
assert html == '<img src="/photo.jpg" alt="Photo">'
def test_input(self):
html = r('(input :type "text" :name "q")')
assert html == '<input type="text" name="q">'
def test_hr(self):
assert r("(hr)") == "<hr>"
def test_meta(self):
html = r('(meta :charset "utf-8")')
assert html == '<meta charset="utf-8">'
def test_link(self):
html = r('(link :rel "stylesheet" :href "/s.css")')
assert html == '<link rel="stylesheet" href="/s.css">'
def test_void_no_closing_tag(self):
# Void elements must not have a closing tag
html = r("(br)")
assert "</br>" not in html
# ---------------------------------------------------------------------------
# Fragment (<>)
# ---------------------------------------------------------------------------
class TestFragments:
def test_basic_fragment(self):
assert r('(<> (li "A") (li "B"))') == "<li>A</li><li>B</li>"
def test_empty_fragment(self):
assert r("(<>)") == ""
def test_nested_fragment(self):
html = r('(ul (<> (li "1") (li "2")))')
assert html == "<ul><li>1</li><li>2</li></ul>"
# ---------------------------------------------------------------------------
# raw!
# ---------------------------------------------------------------------------
class TestRawHtml:
def test_raw_string(self):
assert r('(raw! "<b>bold</b>")') == "<b>bold</b>"
def test_raw_no_escaping(self):
html = r('(raw! "<script>alert(1)</script>")')
assert "<script>" in html
def test_raw_with_variable(self):
env = {"content": "<em>hi</em>"}
html = render(parse("(raw! content)"), env)
assert html == "<em>hi</em>"
# ---------------------------------------------------------------------------
# Components (defcomp / ~prefix)
# ---------------------------------------------------------------------------
class TestComponents:
def test_basic_component(self):
env = {}
evaluate(parse('(defcomp ~badge (&key label) (span :class "badge" label))'), env)
html = render(parse('(~badge :label "New")'), env)
assert html == '<span class="badge">New</span>'
def test_component_with_children(self):
env = {}
evaluate(parse(
'(defcomp ~card (&key title &rest children)'
' (div :class "card" (h2 title) children))'
), env)
html = render(parse('(~card :title "Hi" (p "Body"))'), env)
assert html == '<div class="card"><h2>Hi</h2><p>Body</p></div>'
def test_nested_components(self):
env = {}
evaluate(parse('(defcomp ~inner (&key text) (em text))'), env)
evaluate(parse(
'(defcomp ~outer (&key label)'
' (div (~inner :text label)))'
), env)
html = render(parse('(~outer :label "Hello")'), env)
assert html == "<div><em>Hello</em></div>"
def test_component_with_conditional(self):
env = {}
evaluate(parse(
'(defcomp ~maybe (&key show text)'
' (when show (span text)))'
), env)
assert render(parse('(~maybe :show true :text "Yes")'), env) == "<span>Yes</span>"
assert render(parse('(~maybe :show false :text "No")'), env) == ""
# ---------------------------------------------------------------------------
# Expressions in render context
# ---------------------------------------------------------------------------
class TestExpressions:
def test_let_in_render(self):
html = r('(let ((x "Hello")) (p x))')
assert html == "<p>Hello</p>"
def test_if_true_branch(self):
html = r('(if true (p "Yes") (p "No"))')
assert html == "<p>Yes</p>"
def test_if_false_branch(self):
html = r('(if false (p "Yes") (p "No"))')
assert html == "<p>No</p>"
def test_when_true(self):
html = r('(when true (p "Shown"))')
assert html == "<p>Shown</p>"
def test_when_false(self):
html = r('(when false (p "Hidden"))')
assert html == ""
def test_map_rendering(self):
html = r('(map (lambda (x) (li x)) (list "A" "B" "C"))')
assert html == "<li>A</li><li>B</li><li>C</li>"
def test_variable_in_element(self):
env = {"name": "World"}
html = render(parse('(p (str "Hello " name))'), env)
assert html == "<p>Hello World</p>"
def test_str_in_attr(self):
env = {"slug": "apple"}
html = render(parse('(a :href (str "/p/" slug) "Link")'), env)
assert html == '<a href="/p/apple">Link</a>'
# ---------------------------------------------------------------------------
# Full page structure
# ---------------------------------------------------------------------------
class TestFullPage:
def test_simple_page(self):
html = r('''
(html :lang "en"
(head (title "Test"))
(body (h1 "Hello")))
''')
assert '<html lang="en">' in html
assert "<title>Test</title>" in html
assert "<h1>Hello</h1>" in html
assert "</body>" in html
assert "</html>" in html
def test_form(self):
html = r('''
(form :method "post" :action "/login"
(label :for "user" "Username")
(input :type "text" :name "user" :required true)
(button :type "submit" "Login"))
''')
assert '<form method="post" action="/login">' in html
assert '<label for="user">Username</label>' in html
assert 'required' in html
assert "<button" in html
def test_table(self):
html = r('''
(table
(thead (tr (th "Name") (th "Price")))
(tbody
(tr (td "Apple") (td "1.50"))
(tr (td "Banana") (td "0.75"))))
''')
assert "<thead>" in html
assert "<th>Name</th>" in html
assert "<td>Apple</td>" in html
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_empty_list(self):
assert render([], {}) == ""
def test_none(self):
assert render(None, {}) == ""
def test_dict_not_rendered(self):
assert render({"a": 1}, {}) == ""
def test_number_list(self):
# A list of plain numbers (not a call) renders each
assert render([1, 2, 3], {}) == "123"
def test_string_list(self):
assert render(["a", "b"], {}) == "ab"

View File

@@ -0,0 +1,201 @@
"""Tests for the Jinja ↔ s-expression bridge."""
import asyncio
from shared.sx.jinja_bridge import (
sx,
sx_async,
register_components,
get_component_env,
_COMPONENT_ENV,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(coro):
return asyncio.run(coro)
def setup_function():
"""Clear component env before each test."""
_COMPONENT_ENV.clear()
# ---------------------------------------------------------------------------
# sx() — synchronous rendering
# ---------------------------------------------------------------------------
class TestSx:
def test_simple_html(self):
assert sx('(div "Hello")') == "<div>Hello</div>"
def test_with_kwargs(self):
html = sx('(p name)', name="Alice")
assert html == "<p>Alice</p>"
def test_multiple_kwargs(self):
html = sx('(a :href url title)', url="/about", title="About")
assert html == '<a href="/about">About</a>'
def test_escaping(self):
html = sx('(p text)', text="<script>alert(1)</script>")
assert "&lt;script&gt;" in html
assert "<script>" not in html
def test_nested(self):
html = sx('(div :class "card" (h1 title))', title="Hi")
assert html == '<div class="card"><h1>Hi</h1></div>'
# ---------------------------------------------------------------------------
# register_components() + sx()
# ---------------------------------------------------------------------------
class TestComponents:
def test_register_and_use(self):
register_components('''
(defcomp ~badge (&key label)
(span :class "badge" label))
''')
html = sx('(~badge :label "New")')
assert html == '<span class="badge">New</span>'
def test_multiple_components(self):
register_components('''
(defcomp ~tag (&key text)
(span :class "tag" text))
(defcomp ~pill (&key text)
(span :class "pill" text))
''')
assert '<span class="tag">A</span>' == sx('(~tag :text "A")')
assert '<span class="pill">B</span>' == sx('(~pill :text "B")')
def test_component_with_children(self):
register_components('''
(defcomp ~box (&key title &rest children)
(div :class "box" (h2 title) children))
''')
html = sx('(~box :title "Box" (p "Content"))')
assert '<div class="box">' in html
assert "<h2>Box</h2>" in html
assert "<p>Content</p>" in html
def test_component_with_kwargs_override(self):
"""Kwargs passed to sx() are available alongside components."""
register_components('''
(defcomp ~greeting (&key name)
(p (str "Hello " name)))
''')
html = sx('(~greeting :name user)', user="Bob")
assert html == "<p>Hello Bob</p>"
def test_component_env_persists(self):
"""Components registered once are available in subsequent calls."""
register_components('(defcomp ~x (&key v) (b v))')
assert sx('(~x :v "1")') == "<b>1</b>"
assert sx('(~x :v "2")') == "<b>2</b>"
def test_get_component_env(self):
register_components('(defcomp ~foo (&key x) (span x))')
env = get_component_env()
assert "~foo" in env
# ---------------------------------------------------------------------------
# Link card example — the first migration target
# ---------------------------------------------------------------------------
class TestLinkCard:
def setup_method(self):
_COMPONENT_ENV.clear()
register_components('''
(defcomp ~link-card (&key link title image icon brand)
(a :href link
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
(div :class "flex flex-row items-start gap-3 p-3"
(if image
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
(i :class icon)))
(div :class "flex-1 min-w-0"
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
(when brand
(div :class "text-xs text-stone-500 mt-0.5" brand))))))
''')
def test_with_image(self):
html = sx('''
(~link-card
:link "/products/apple/"
:title "Apple"
:image "/img/apple.jpg"
:icon "fas fa-shopping-bag")
''')
assert 'href="/products/apple/"' in html
assert '<img src="/img/apple.jpg"' in html
assert "Apple" in html
def test_without_image(self):
html = sx('''
(~link-card
:link "/posts/hello/"
:title "Hello World"
:icon "fas fa-file-alt")
''')
assert 'href="/posts/hello/"' in html
assert "<img" not in html
assert "fas fa-file-alt" in html
assert "Hello World" in html
def test_with_brand(self):
html = sx('''
(~link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg"
:brand "Acme Corp")
''')
assert "Acme Corp" in html
def test_without_brand(self):
html = sx('''
(~link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg")
''')
# brand div should not appear
assert "mt-0.5" not in html
def test_kwargs_from_python(self):
"""Pass data from Python (like a route handler would)."""
html = sx(
'(~link-card :link link :title title :image image :icon "fas fa-box")',
link="/products/banana/",
title="Banana",
image="/img/banana.jpg",
)
assert 'href="/products/banana/"' in html
assert "Banana" in html
# ---------------------------------------------------------------------------
# sx_async() — async rendering (no real I/O, just passthrough)
# ---------------------------------------------------------------------------
class TestSxAsync:
def test_simple(self):
html = run(sx_async('(div "Async")'))
assert html == "<div>Async</div>"
def test_with_kwargs(self):
html = run(sx_async('(p name)', name="Alice"))
assert html == "<p>Alice</p>"
def test_with_component(self):
register_components('(defcomp ~x (&key v) (b v))')
html = run(sx_async('(~x :v "OK")'))
assert html == "<b>OK</b>"

View File

@@ -0,0 +1,35 @@
"""Verify every .sx and .sx file in the repo parses without errors."""
import os
import pytest
from shared.sx.parser import parse_all
def _collect_sx_files():
"""Find all .sx and .sx files under the repo root."""
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
files = []
for dirpath, _dirs, filenames in os.walk(repo):
if "node_modules" in dirpath or ".git" in dirpath or "artdag" in dirpath:
continue
for fn in filenames:
if fn.endswith((".sx", ".sx")):
files.append(os.path.join(dirpath, fn))
return sorted(files)
_SX_FILES = _collect_sx_files()
@pytest.mark.parametrize("path", _SX_FILES, ids=[
os.path.relpath(p) for p in _SX_FILES
])
def test_parse(path):
"""Each sx file should parse without errors."""
with open(path) as f:
source = f.read()
exprs = parse_all(source)
assert len(exprs) > 0, f"{path} produced no expressions"

View File

@@ -0,0 +1,191 @@
"""Tests for the s-expression parser."""
import pytest
from shared.sx.parser import parse, parse_all, serialize, ParseError
from shared.sx.types import Symbol, Keyword, NIL
# ---------------------------------------------------------------------------
# Atoms
# ---------------------------------------------------------------------------
class TestAtoms:
def test_integer(self):
assert parse("42") == 42
def test_negative_integer(self):
assert parse("-7") == -7
def test_float(self):
assert parse("3.14") == 3.14
def test_scientific(self):
assert parse("1e-3") == 0.001
def test_string(self):
assert parse('"hello world"') == "hello world"
def test_string_escapes(self):
assert parse(r'"line1\nline2"') == "line1\nline2"
assert parse(r'"tab\there"') == "tab\there"
assert parse(r'"say \"hi\""') == 'say "hi"'
def test_symbol(self):
assert parse("foo") == Symbol("foo")
def test_component_symbol(self):
s = parse("~card")
assert s == Symbol("~card")
assert s.is_component
def test_keyword(self):
assert parse(":class") == Keyword("class")
def test_true(self):
assert parse("true") is True
def test_false(self):
assert parse("false") is False
def test_nil(self):
assert parse("nil") is NIL
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
class TestLists:
def test_empty_list(self):
assert parse("()") == []
def test_simple_list(self):
assert parse("(1 2 3)") == [1, 2, 3]
def test_mixed_list(self):
result = parse('(div :class "main")')
assert result == [Symbol("div"), Keyword("class"), "main"]
def test_nested_list(self):
result = parse("(a (b c) d)")
assert result == [Symbol("a"), [Symbol("b"), Symbol("c")], Symbol("d")]
def test_vector_sugar(self):
assert parse("[1 2 3]") == [1, 2, 3]
# ---------------------------------------------------------------------------
# Maps
# ---------------------------------------------------------------------------
class TestMaps:
def test_simple_map(self):
result = parse('{:a 1 :b 2}')
assert result == {"a": 1, "b": 2}
def test_nested_map(self):
result = parse('{:x {:y 3}}')
assert result == {"x": {"y": 3}}
def test_string_keys(self):
result = parse('{"name" "alice"}')
assert result == {"name": "alice"}
# ---------------------------------------------------------------------------
# Comments
# ---------------------------------------------------------------------------
class TestComments:
def test_line_comment(self):
assert parse("; comment\n42") == 42
def test_inline_comment(self):
result = parse("(a ; stuff\nb)")
assert result == [Symbol("a"), Symbol("b")]
# ---------------------------------------------------------------------------
# parse_all
# ---------------------------------------------------------------------------
class TestParseAll:
def test_multiple(self):
results = parse_all("1 2 3")
assert results == [1, 2, 3]
def test_multiple_lists(self):
results = parse_all("(a) (b)")
assert results == [[Symbol("a")], [Symbol("b")]]
def test_empty(self):
assert parse_all("") == []
assert parse_all(" ; only comments\n") == []
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class TestErrors:
def test_unterminated_list(self):
with pytest.raises(ParseError):
parse("(a b")
def test_unterminated_string(self):
with pytest.raises(ParseError):
parse('"hello')
def test_unexpected_closer(self):
with pytest.raises(ParseError):
parse(")")
def test_trailing_content(self):
with pytest.raises(ParseError):
parse("1 2")
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
class TestSerialize:
def test_int(self):
assert serialize(42) == "42"
def test_float(self):
assert serialize(3.14) == "3.14"
def test_string(self):
assert serialize("hello") == '"hello"'
def test_string_escapes(self):
assert serialize('say "hi"') == '"say \\"hi\\""'
def test_symbol(self):
assert serialize(Symbol("foo")) == "foo"
def test_keyword(self):
assert serialize(Keyword("class")) == ":class"
def test_bool(self):
assert serialize(True) == "true"
assert serialize(False) == "false"
def test_nil(self):
assert serialize(None) == "nil"
assert serialize(NIL) == "nil"
def test_list(self):
assert serialize([Symbol("a"), 1, 2]) == "(a 1 2)"
def test_empty_list(self):
assert serialize([]) == "()"
def test_dict(self):
result = serialize({"a": 1, "b": 2})
assert result == "{:a 1 :b 2}"
def test_roundtrip(self):
original = '(div :class "main" (p "hello") (span 42))'
assert serialize(parse(original)) == original

View File

@@ -0,0 +1,245 @@
"""Tests for the relation registry (Phase A)."""
import pytest
from shared.sx.evaluator import evaluate, EvalError
from shared.sx.parser import parse
from shared.sx.relations import (
_RELATION_REGISTRY,
clear_registry,
get_relation,
load_relation_registry,
relations_from,
relations_to,
all_relations,
)
from shared.sx.types import RelationDef
@pytest.fixture(autouse=True)
def _clean_registry():
"""Clear registry before each test."""
clear_registry()
# ---------------------------------------------------------------------------
# defrelation parsing
# ---------------------------------------------------------------------------
class TestDefrelation:
def test_basic_defrelation(self):
tree = parse('''
(defrelation :page->market
:from "page"
:to "market"
:cardinality :one-to-many
:inverse :market->page
:nav :submenu
:nav-icon "fa fa-shopping-bag"
:nav-label "markets")
''')
result = evaluate(tree)
assert isinstance(result, RelationDef)
assert result.name == "page->market"
assert result.from_type == "page"
assert result.to_type == "market"
assert result.cardinality == "one-to-many"
assert result.inverse == "market->page"
assert result.nav == "submenu"
assert result.nav_icon == "fa fa-shopping-bag"
assert result.nav_label == "markets"
def test_defrelation_registered(self):
tree = parse('''
(defrelation :a->b
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
''')
evaluate(tree)
assert get_relation("a->b") is not None
assert get_relation("a->b").cardinality == "one-to-one"
def test_defrelation_one_to_one(self):
tree = parse('''
(defrelation :page->menu_node
:from "page" :to "menu_node"
:cardinality :one-to-one :nav :hidden)
''')
result = evaluate(tree)
assert result.cardinality == "one-to-one"
assert result.inverse is None
assert result.nav == "hidden"
def test_defrelation_many_to_many(self):
tree = parse('''
(defrelation :post->entry
:from "post" :to "calendar_entry"
:cardinality :many-to-many
:inverse :entry->post
:nav :inline
:nav-icon "fa fa-file-alt"
:nav-label "events")
''')
result = evaluate(tree)
assert result.cardinality == "many-to-many"
def test_default_nav_is_hidden(self):
tree = parse('''
(defrelation :x->y
:from "x" :to "y" :cardinality :one-to-many)
''')
result = evaluate(tree)
assert result.nav == "hidden"
def test_invalid_cardinality_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :wrong)
''')
with pytest.raises(EvalError, match="invalid cardinality"):
evaluate(tree)
def test_invalid_nav_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
''')
with pytest.raises(EvalError, match="invalid nav"):
evaluate(tree)
def test_missing_from_raises(self):
tree = parse('''
(defrelation :bad :to "b" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :from"):
evaluate(tree)
def test_missing_to_raises(self):
tree = parse('''
(defrelation :bad :from "a" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :to"):
evaluate(tree)
def test_missing_cardinality_raises(self):
tree = parse('''
(defrelation :bad :from "a" :to "b")
''')
with pytest.raises(EvalError, match="missing required :cardinality"):
evaluate(tree)
def test_name_must_be_keyword(self):
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
with pytest.raises(EvalError, match="must be a keyword"):
evaluate(tree)
# ---------------------------------------------------------------------------
# Registry queries
# ---------------------------------------------------------------------------
class TestRegistry:
def _load_sample(self):
tree = parse('''
(begin
(defrelation :page->market
:from "page" :to "market" :cardinality :one-to-many
:nav :submenu :nav-icon "fa fa-shopping-bag" :nav-label "markets")
(defrelation :page->calendar
:from "page" :to "calendar" :cardinality :one-to-many
:nav :submenu :nav-icon "fa fa-calendar" :nav-label "calendars")
(defrelation :post->entry
:from "post" :to "calendar_entry" :cardinality :many-to-many
:nav :inline)
(defrelation :page->menu_node
:from "page" :to "menu_node" :cardinality :one-to-one
:nav :hidden))
''')
evaluate(tree)
def test_get_relation(self):
self._load_sample()
rel = get_relation("page->market")
assert rel is not None
assert rel.to_type == "market"
def test_get_relation_not_found(self):
assert get_relation("nonexistent") is None
def test_relations_from_page(self):
self._load_sample()
rels = relations_from("page")
names = {r.name for r in rels}
assert names == {"page->market", "page->calendar", "page->menu_node"}
def test_relations_from_post(self):
self._load_sample()
rels = relations_from("post")
assert len(rels) == 1
assert rels[0].name == "post->entry"
def test_relations_from_empty(self):
self._load_sample()
assert relations_from("nonexistent") == []
def test_relations_to_market(self):
self._load_sample()
rels = relations_to("market")
assert len(rels) == 1
assert rels[0].name == "page->market"
def test_relations_to_calendar_entry(self):
self._load_sample()
rels = relations_to("calendar_entry")
assert len(rels) == 1
assert rels[0].name == "post->entry"
def test_all_relations(self):
self._load_sample()
assert len(all_relations()) == 4
def test_clear_registry(self):
self._load_sample()
assert len(all_relations()) == 4
clear_registry()
assert len(all_relations()) == 0
# ---------------------------------------------------------------------------
# load_relation_registry() — built-in definitions
# ---------------------------------------------------------------------------
class TestLoadBuiltins:
def test_loads_builtin_relations(self):
load_relation_registry()
assert get_relation("page->market") is not None
assert get_relation("page->calendar") is not None
assert get_relation("post->calendar_entry") is not None
assert get_relation("page->menu_node") is not None
def test_builtin_page_market(self):
load_relation_registry()
rel = get_relation("page->market")
assert rel.from_type == "page"
assert rel.to_type == "market"
assert rel.cardinality == "one-to-many"
assert rel.inverse == "market->page"
assert rel.nav == "submenu"
assert rel.nav_icon == "fa fa-shopping-bag"
def test_builtin_post_entry(self):
load_relation_registry()
rel = get_relation("post->calendar_entry")
assert rel.cardinality == "many-to-many"
assert rel.nav == "inline"
def test_builtin_page_menu_node(self):
load_relation_registry()
rel = get_relation("page->menu_node")
assert rel.cardinality == "one-to-one"
assert rel.nav == "hidden"
def test_frozen_dataclass(self):
load_relation_registry()
rel = get_relation("page->market")
with pytest.raises(AttributeError):
rel.name = "changed"

View File

@@ -0,0 +1,320 @@
"""Tests for the async resolver.
Uses asyncio.run() directly — no pytest-asyncio dependency needed.
Mocks execute_io at the resolver boundary to avoid infrastructure imports.
"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from shared.sx import parse, evaluate
from shared.sx.resolver import resolve, _collect_io, _IONode
from shared.sx.primitives_io import RequestContext, execute_io
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(coro):
"""Run an async coroutine synchronously."""
return asyncio.run(coro)
async def r(text, env=None, ctx=None):
"""Parse and resolve a single expression."""
return await resolve(parse(text), ctx=ctx, env=env)
def mock_io(**responses):
"""Patch execute_io to return canned responses by primitive name.
Usage::
with mock_io(frag='<b>Card</b>', query={"title": "Apple"}):
html = run(r('...'))
For dynamic responses, pass a callable::
async def frag_handler(args, kwargs, ctx):
return f"<b>{args[1]}</b>"
with mock_io(frag=frag_handler):
...
"""
async def side_effect(name, args, kwargs, ctx):
val = responses.get(name)
if val is None:
# Delegate to real handler for context primitives
if name == "current-user":
return ctx.user
if name == "htmx-request?":
return ctx.is_htmx
return None
if callable(val) and asyncio.iscoroutinefunction(val):
return await val(args, kwargs, ctx)
if callable(val):
return val(args, kwargs, ctx)
return val
return patch("shared.sx.resolver.execute_io", side_effect=side_effect)
# ---------------------------------------------------------------------------
# Basic rendering (no I/O) — resolver should pass through to HTML renderer
# ---------------------------------------------------------------------------
class TestPassthrough:
def test_simple_html(self):
assert run(r('(div "Hello")')) == "<div>Hello</div>"
def test_nested_html(self):
assert run(r('(div (p "World"))')) == "<div><p>World</p></div>"
def test_with_env(self):
assert run(r('(p name)', env={"name": "Alice"})) == "<p>Alice</p>"
def test_component(self):
env = {}
evaluate(parse('(defcomp ~tag (&key label) (span :class "tag" label))'), env)
assert run(r('(~tag :label "New")', env=env)) == '<span class="tag">New</span>'
# ---------------------------------------------------------------------------
# I/O node collection
# ---------------------------------------------------------------------------
class TestCollectIO:
def test_finds_frag(self):
expr = parse('(div (frag "blog" "link-card" :slug "apple"))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "frag"
def test_finds_query(self):
expr = parse('(div (query "market" "products" :ids "1,2"))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "query"
def test_finds_multiple(self):
expr = parse('''
(div
(frag "blog" "card" :slug "a")
(query "market" "products" :ids "1"))
''')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 2
def test_finds_current_user(self):
expr = parse('(div (current-user))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "current-user"
def test_finds_htmx_request(self):
expr = parse('(div (htmx-request?))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "htmx-request?"
def test_no_io_nodes(self):
expr = parse('(div (p "Hello"))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 0
def test_evaluates_kwargs(self):
expr = parse('(query "market" "products" :slug slug)')
env = {"slug": "apple"}
nodes: list[_IONode] = []
_collect_io(expr, env, nodes)
assert len(nodes) == 1
assert nodes[0].kwargs["slug"] == "apple"
def test_positional_args_evaluated(self):
expr = parse('(frag app frag_type)')
env = {"app": "blog", "frag_type": "card"}
nodes: list[_IONode] = []
_collect_io(expr, env, nodes)
assert nodes[0].args == ["blog", "card"]
# ---------------------------------------------------------------------------
# Fragment resolution (mocked)
# ---------------------------------------------------------------------------
class TestFragResolution:
def test_frag_substitution(self):
"""Fragment result is substituted as raw HTML."""
with mock_io(frag='<a href="/apple">Apple</a>'):
html = run(r('(div (frag "blog" "link-card" :slug "apple"))'))
assert '<a href="/apple">Apple</a>' in html
assert "&lt;" not in html # should NOT be escaped
def test_frag_with_surrounding(self):
"""Fragment result sits alongside static HTML."""
with mock_io(frag="<span>Card</span>"):
html = run(r('(div (h1 "Title") (frag "blog" "card" :slug "x"))'))
assert "<h1>Title</h1>" in html
assert "<span>Card</span>" in html
def test_frag_params_forwarded(self):
"""Keyword args are forwarded to the I/O handler."""
received = {}
async def capture_frag(args, kwargs, ctx):
received.update(kwargs)
return "<b>ok</b>"
with mock_io(frag=capture_frag):
run(r('(frag "blog" "card" :slug "apple" :size "large")'))
assert received == {"slug": "apple", "size": "large"}
# ---------------------------------------------------------------------------
# Query resolution (mocked)
# ---------------------------------------------------------------------------
class TestQueryResolution:
def test_query_result_dict(self):
"""Query returning a dict renders as empty (dicts aren't renderable)."""
with mock_io(query={"title": "Apple"}):
html = run(r('(query "market" "product" :slug "apple")'))
assert html == ""
def test_query_returns_list(self):
"""Query returning a list of strings renders them."""
with mock_io(query=["Apple", "Banana"]):
html = run(r('(query "market" "product-names")'))
assert "Apple" in html
assert "Banana" in html
# ---------------------------------------------------------------------------
# Parallel I/O
# ---------------------------------------------------------------------------
class TestParallelIO:
def test_parallel_fetches(self):
"""Multiple I/O nodes are fetched concurrently."""
call_count = {"n": 0}
async def counting_frag(args, kwargs, ctx):
call_count["n"] += 1
await asyncio.sleep(0.01)
return f"<div>{args[1]}</div>"
with mock_io(frag=counting_frag):
html = run(r('''
(div
(frag "blog" "card-a")
(frag "blog" "card-b")
(frag "blog" "card-c"))
'''))
assert "<div>card-a</div>" in html
assert "<div>card-b</div>" in html
assert "<div>card-c</div>" in html
assert call_count["n"] == 3
# ---------------------------------------------------------------------------
# Request context primitives
# ---------------------------------------------------------------------------
class TestRequestContext:
def test_current_user(self):
user = {"id": 1, "name": "Alice"}
ctx = RequestContext(user=user)
result = run(execute_io("current-user", [], {}, ctx))
assert result == user
def test_htmx_true(self):
ctx = RequestContext(is_htmx=True)
assert run(execute_io("htmx-request?", [], {}, ctx)) is True
def test_htmx_false(self):
ctx = RequestContext(is_htmx=False)
assert run(execute_io("htmx-request?", [], {}, ctx)) is False
def test_no_user(self):
ctx = RequestContext()
assert run(execute_io("current-user", [], {}, ctx)) is None
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestErrorHandling:
def test_frag_error_degrades_gracefully(self):
"""Failed I/O substitutes empty string, doesn't crash."""
async def failing_frag(args, kwargs, ctx):
raise ConnectionError("connection refused")
with mock_io(frag=failing_frag):
html = run(r('(div (h1 "Title") (frag "blog" "broken"))'))
assert "<h1>Title</h1>" in html
assert "<div>" in html
def test_query_error_degrades_gracefully(self):
"""Failed query substitutes empty string."""
async def failing_query(args, kwargs, ctx):
raise TimeoutError("timeout")
with mock_io(query=failing_query):
html = run(r('(div (p "Static") (query "market" "broken"))'))
assert "<p>Static</p>" in html
# ---------------------------------------------------------------------------
# Mixed static + I/O
# ---------------------------------------------------------------------------
class TestMixedContent:
def test_static_and_frag(self):
with mock_io(frag="<span>Dynamic</span>"):
html = run(r('''
(div
(h1 "Static Title")
(p "Static body")
(frag "blog" "widget"))
'''))
assert "<h1>Static Title</h1>" in html
assert "<p>Static body</p>" in html
assert "<span>Dynamic</span>" in html
def test_multiple_frag_types(self):
"""Different fragment types in one tree."""
async def dynamic_frag(args, kwargs, ctx):
return f"<b>{args[1]}</b>"
with mock_io(frag=dynamic_frag):
html = run(r('''
(div
(frag "blog" "header")
(frag "market" "sidebar"))
'''))
assert "<b>header</b>" in html
assert "<b>sidebar</b>" in html
def test_frag_and_query_together(self):
"""Tree with both frag and query nodes."""
async def mock_handler(args, kwargs, ctx):
name = args[1] if len(args) > 1 else "?"
return f"<i>{name}</i>"
with mock_io(frag=mock_handler, query="data"):
html = run(r('''
(div
(frag "blog" "card")
(query "market" "stats"))
'''))
assert "<i>card</i>" in html
assert "data" in html

View File

@@ -0,0 +1,233 @@
"""Test sx.js string renderer matches Python renderer output.
Runs sx.js through Node.js and compares output with Python.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from shared.sx.parser import parse, parse_all
from shared.sx.html import render as py_render
from shared.sx.evaluator import evaluate
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js in Node and return the renderToString result."""
# Build a small Node script
script = f"""
global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);
"""
result = subprocess.run(
["node", "-e", script],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
pytest.fail(f"Node.js error:\n{result.stderr}")
return result.stdout
class TestParserParity:
"""Parser produces equivalent structures."""
def test_simple_element(self):
assert _js_render('(div "hello")') == '<div>hello</div>'
def test_nested_elements(self):
html = _js_render('(div :class "card" (p "text"))')
assert html == '<div class="card"><p>text</p></div>'
def test_void_element(self):
assert _js_render('(img :src "a.jpg")') == '<img src="a.jpg">'
assert _js_render('(br)') == '<br>'
def test_boolean_attr(self):
assert _js_render('(input :disabled true :type "text")') == '<input disabled type="text">'
def test_nil_attr_omitted(self):
assert _js_render('(div :class nil "hi")') == '<div>hi</div>'
def test_false_attr_omitted(self):
assert _js_render('(div :class false "hi")') == '<div>hi</div>'
def test_numbers(self):
assert _js_render('(span 42)') == '<span>42</span>'
def test_escaping(self):
html = _js_render('(div "<script>alert(1)</script>")')
assert "&lt;script&gt;" in html
class TestSpecialForms:
"""Special forms render correctly."""
def test_if_true(self):
assert _js_render('(if true (span "yes") (span "no"))') == '<span>yes</span>'
def test_if_false(self):
assert _js_render('(if false (span "yes") (span "no"))') == '<span>no</span>'
def test_if_nil(self):
assert _js_render('(if nil (span "yes") (span "no"))') == '<span>no</span>'
def test_when_true(self):
assert _js_render('(when true (span "yes"))') == '<span>yes</span>'
def test_when_false(self):
assert _js_render('(when false (span "yes"))') == ''
def test_str(self):
assert _js_render('(div (str "a" "b" "c"))') == '<div>abc</div>'
def test_fragment(self):
assert _js_render('(<> (span "a") (span "b"))') == '<span>a</span><span>b</span>'
def test_let(self):
assert _js_render('(let ((x "hello")) (div x))') == '<div>hello</div>'
def test_let_clojure_style(self):
assert _js_render('(let (x "hello" y "world") (div (str x " " y)))') == '<div>hello world</div>'
def test_and(self):
assert _js_render('(when (and true true) (span "ok"))') == '<span>ok</span>'
assert _js_render('(when (and true false) (span "ok"))') == ''
def test_or(self):
assert _js_render('(div (or nil "fallback"))') == '<div>fallback</div>'
class TestComponents:
"""Component definition and rendering."""
CARD = '(defcomp ~card (&key title) (div :class "card" (h2 title)))'
def test_simple_component(self):
html = _js_render('(~card :title "Hello")', self.CARD)
assert html == '<div class="card"><h2>Hello</h2></div>'
def test_component_with_children(self):
comp = '(defcomp ~box (&key &rest children) (div :class "box" (raw! children)))'
html = _js_render('(~box (p "inside"))', comp)
assert html == '<div class="box"><p>inside</p></div>'
def test_component_with_conditional(self):
comp = '(defcomp ~badge (&key show label) (when show (span label)))'
assert _js_render('(~badge :show true :label "ok")', comp) == '<span>ok</span>'
assert _js_render('(~badge :show false :label "ok")', comp) == ''
def test_nested_components(self):
comps = """
(defcomp ~inner (&key text) (span text))
(defcomp ~outer (&key label) (div (~inner :text label)))
"""
html = _js_render('(~outer :label "hi")', comps)
assert html == '<div><span>hi</span></div>'
class TestClientComponentsTag:
"""client_components_tag() generates valid sx for JS consumption."""
def test_emits_script_tag(self):
from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
# Register a test component
register_components('(defcomp ~test-cct (&key label) (span label))')
try:
tag = client_components_tag("test-cct")
assert tag.startswith('<script type="text/sx" data-components>')
assert tag.endswith('</script>')
assert "defcomp ~test-cct" in tag
finally:
_COMPONENT_ENV.pop("~test-cct", None)
def test_roundtrip_through_js(self):
"""Component emitted by client_components_tag renders identically in JS."""
from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
register_components('(defcomp ~test-rt (&key title) (div :class "rt" title))')
try:
tag = client_components_tag("test-rt")
# Extract the sx source from the script tag
sx_source = tag.replace('<script type="text/sx" data-components>', '').replace('</script>', '')
js_html = _js_render('(~test-rt :title "hello")', sx_source)
py_html = py_render(parse('(~test-rt :title "hello")'), _COMPONENT_ENV)
assert js_html == py_html
finally:
_COMPONENT_ENV.pop("~test-rt", None)
class TestPythonParity:
"""JS string renderer matches Python renderer output."""
CASES = [
'(div :class "main" (p "hello"))',
'(div (if true "yes" "no"))',
'(div (when false "hidden"))',
'(span (str "a" "-" "b"))',
'(<> (div "one") (div "two"))',
'(ul (li "a") (li "b") (li "c"))',
'(input :type "text" :disabled true :value "x")',
'(div :class nil :id "ok" "text")',
'(img :src "photo.jpg" :alt "A photo")',
'(table (tr (td "cell")))',
]
@pytest.mark.parametrize("sx_text", CASES)
def test_matches_python(self, sx_text):
py_html = py_render(parse(sx_text))
js_html = _js_render(sx_text)
assert js_html == py_html, f"Mismatch for {sx_text!r}:\n PY: {py_html!r}\n JS: {js_html!r}"
COMP_CASES = [
(
'(defcomp ~tag (&key label colour) (span :class (str "tag-" colour) label))',
'(~tag :label "new" :colour "red")',
),
(
'(defcomp ~wrap (&key &rest children) (div :class "w" (raw! children)))',
'(~wrap (p "a") (p "b"))',
),
]
@pytest.mark.parametrize("comp_text,call_text", COMP_CASES)
def test_component_matches_python(self, comp_text, call_text):
env = {}
evaluate(parse(comp_text), env)
py_html = py_render(parse(call_text), env)
js_html = _js_render(call_text, comp_text)
assert js_html == py_html
MAP_CASES = [
# map with lambda returning HTML element
(
"",
'(ul (map (lambda (x) (li x)) ("a" "b" "c")))',
),
# map with lambda returning component
(
'(defcomp ~item (&key name) (span :class "item" name))',
'(div (map (lambda (t) (~item :name (get t "name"))) ({"name" "Alice"} {"name" "Bob"})))',
),
# map-indexed with lambda
(
"",
'(ul (map-indexed (lambda (i x) (li (str i ". " x))) ("foo" "bar")))',
),
]
@pytest.mark.parametrize("comp_text,call_text", MAP_CASES)
def test_map_lambda_render(self, comp_text, call_text):
env = {}
if comp_text:
for expr in parse_all(comp_text):
evaluate(expr, env)
py_html = py_render(parse(call_text), env)
js_html = _js_render(call_text, comp_text)
assert js_html == py_html, f"Mismatch:\n PY: {py_html!r}\n JS: {js_html!r}"