Component names now reflect filesystem location using / as path separator and : as namespace separator for shared components: ~sx-header → ~layouts/header ~layout-app-body → ~shared:layout/app-body ~blog-admin-dashboard → ~admin/dashboard 209 files, 4,941 replacements across all services. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:fragments/cart-mini
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCartMini:
|
|
def test_empty_cart_shows_logo(self):
|
|
html = sx(
|
|
'(~shared:fragments/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(
|
|
'(~shared:fragments/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(
|
|
'(~shared:fragments/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(
|
|
'(~shared:fragments/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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:fragments/account-nav-item
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAccountNavItem:
|
|
def test_renders_link(self):
|
|
html = sx(
|
|
'(~shared:fragments/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(
|
|
'(~shared:fragments/account-nav-item :href "/cart/orders/" :label "my orders")',
|
|
)
|
|
assert ">my orders<" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:navigation/calendar-entry-nav
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCalendarEntryNav:
|
|
def test_renders_entry(self):
|
|
html = sx(
|
|
'(~shared:navigation/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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:navigation/calendar-link-nav
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCalendarLinkNav:
|
|
def test_renders_calendar_link(self):
|
|
html = sx(
|
|
'(~shared:navigation/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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:navigation/market-link-nav
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMarketLinkNav:
|
|
def test_renders_market_link(self):
|
|
html = sx(
|
|
'(~shared:navigation/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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:cards/post-card
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPostCard:
|
|
def test_basic_card(self):
|
|
html = sx(
|
|
'(~shared:cards/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(
|
|
'(~shared:cards/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(
|
|
'(~shared:cards/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(
|
|
'(~shared:cards/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(
|
|
'(~shared:cards/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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:pages/base-shell and ~shared:pages/error-page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBaseShell:
|
|
def test_renders_full_page(self):
|
|
html = sx(
|
|
'(~shared:pages/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 "tw.css" in html
|
|
|
|
|
|
class TestErrorPage:
|
|
def test_404_page(self):
|
|
html = sx(
|
|
'(~shared:pages/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(
|
|
'(~shared:pages/error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
|
|
**{"asset-url": "/static"},
|
|
)
|
|
assert "SERVER ERROR" in html
|
|
assert "<img" not in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:navigation/relation-nav
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRelationNav:
|
|
def test_renders_link(self):
|
|
html = sx(
|
|
'(~shared:navigation/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(
|
|
'(~shared:navigation/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(
|
|
'(~shared:navigation/relation-nav :href "/" :name "X" :nav-class "custom-class")',
|
|
**{"nav-class": "custom-class"},
|
|
)
|
|
assert 'class="custom-class"' in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:relations/attach
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRelationAttach:
|
|
def test_renders_button(self):
|
|
html = sx(
|
|
'(~shared:relations/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(
|
|
'(~shared:relations/attach :create-url "/create/")',
|
|
**{"create-url": "/create/"},
|
|
)
|
|
assert "Add" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ~shared:relations/detach
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRelationDetach:
|
|
def test_renders_button(self):
|
|
html = sx(
|
|
'(~shared:relations/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(
|
|
'(~shared:relations/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(
|
|
'(~shared:pages/error-page :title "Test" :message "MSG" :asset-url "/s")',
|
|
**{"asset-url": "/s"},
|
|
)
|
|
assert "<!doctype html>" in html
|
|
assert "MSG" in html
|