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:
0
shared/sx/tests/__init__.py
Normal file
0
shared/sx/tests/__init__.py
Normal file
350
shared/sx/tests/test_components.py
Normal file
350
shared/sx/tests/test_components.py
Normal 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
|
||||
326
shared/sx/tests/test_evaluator.py
Normal file
326
shared/sx/tests/test_evaluator.py
Normal 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
|
||||
365
shared/sx/tests/test_html.py
Normal file
365
shared/sx/tests/test_html.py
Normal 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 & B"
|
||||
|
||||
def test_escape_text_lt_gt(self):
|
||||
assert escape_text("<script>") == "<script>"
|
||||
|
||||
def test_escape_attr_quotes(self):
|
||||
assert escape_attr('he said "hi"') == "he said "hi""
|
||||
|
||||
def test_escape_attr_all(self):
|
||||
assert escape_attr('&<>"') == "&<>""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primitives / atoms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAtoms:
|
||||
def test_string(self):
|
||||
assert r('"Hello"') == "Hello"
|
||||
|
||||
def test_string_with_entities(self):
|
||||
assert r('"<b>bold</b>"') == "<b>bold</b>"
|
||||
|
||||
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 & B"></div>'
|
||||
|
||||
def test_attr_with_quotes(self):
|
||||
assert r("""(div :title "say \\"hi\\"")""") == \
|
||||
'<div title="say "hi""></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"
|
||||
201
shared/sx/tests/test_jinja_bridge.py
Normal file
201
shared/sx/tests/test_jinja_bridge.py
Normal 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 "<script>" 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>"
|
||||
35
shared/sx/tests/test_parse_all.py
Normal file
35
shared/sx/tests/test_parse_all.py
Normal 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"
|
||||
191
shared/sx/tests/test_parser.py
Normal file
191
shared/sx/tests/test_parser.py
Normal 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
|
||||
245
shared/sx/tests/test_relations.py
Normal file
245
shared/sx/tests/test_relations.py
Normal 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"
|
||||
320
shared/sx/tests/test_resolver.py
Normal file
320
shared/sx/tests/test_resolver.py
Normal 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 "<" 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
|
||||
233
shared/sx/tests/test_sx_js.py
Normal file
233
shared/sx/tests/test_sx_js.py
Normal 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 "<script>" 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}"
|
||||
Reference in New Issue
Block a user