mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 10:31:08 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
1
backend/tests/ux/__init__.py
Normal file
1
backend/tests/ux/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# UX Component Tests
|
||||
193
backend/tests/ux/test_breadcrumbs.py
Normal file
193
backend/tests/ux/test_breadcrumbs.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Tests for breadcrumb utilities.
|
||||
|
||||
These tests verify that the breadcrumb system generates
|
||||
correct navigation structures and Schema.org markup.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.utils.breadcrumbs import (
|
||||
Breadcrumb,
|
||||
BreadcrumbBuilder,
|
||||
build_breadcrumb,
|
||||
)
|
||||
|
||||
|
||||
class TestBreadcrumb:
|
||||
"""Tests for Breadcrumb dataclass."""
|
||||
|
||||
def test_basic_breadcrumb(self):
|
||||
"""Should create breadcrumb with required fields."""
|
||||
crumb = Breadcrumb(label="Home", url="/")
|
||||
assert crumb.label == "Home"
|
||||
assert crumb.url == "/"
|
||||
assert crumb.icon is None
|
||||
assert crumb.is_current is False
|
||||
|
||||
def test_breadcrumb_with_icon(self):
|
||||
"""Should accept icon parameter."""
|
||||
crumb = Breadcrumb(label="Home", url="/", icon="fas fa-home")
|
||||
assert crumb.icon == "fas fa-home"
|
||||
|
||||
def test_current_breadcrumb(self):
|
||||
"""Should mark breadcrumb as current."""
|
||||
crumb = Breadcrumb(label="Current Page", is_current=True)
|
||||
assert crumb.is_current is True
|
||||
assert crumb.url is None
|
||||
|
||||
def test_schema_position(self):
|
||||
"""Should have default schema position."""
|
||||
crumb = Breadcrumb(label="Test")
|
||||
assert crumb.schema_position == 1
|
||||
|
||||
|
||||
class TestBuildBreadcrumb:
|
||||
"""Tests for build_breadcrumb helper function."""
|
||||
|
||||
def test_basic_breadcrumb(self):
|
||||
"""Should create breadcrumb dict with defaults."""
|
||||
crumb = build_breadcrumb("Home", "/")
|
||||
assert crumb["label"] == "Home"
|
||||
assert crumb["url"] == "/"
|
||||
assert crumb["is_current"] is False
|
||||
|
||||
def test_current_breadcrumb(self):
|
||||
"""Should mark as current when specified."""
|
||||
crumb = build_breadcrumb("Current", is_current=True)
|
||||
assert crumb["is_current"] is True
|
||||
|
||||
def test_breadcrumb_with_icon(self):
|
||||
"""Should include icon when specified."""
|
||||
crumb = build_breadcrumb("Home", "/", icon="fas fa-home")
|
||||
assert crumb["icon"] == "fas fa-home"
|
||||
|
||||
|
||||
class TestBreadcrumbBuilder:
|
||||
"""Tests for BreadcrumbBuilder class."""
|
||||
|
||||
def test_empty_builder(self):
|
||||
"""Should build empty list when no crumbs added."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.build()
|
||||
assert crumbs == []
|
||||
|
||||
def test_add_home(self):
|
||||
"""Should add home breadcrumb with defaults."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_home().build()
|
||||
|
||||
assert len(crumbs) == 1
|
||||
assert crumbs[0].label == "Home"
|
||||
assert crumbs[0].url == "/"
|
||||
assert crumbs[0].icon == "fas fa-home"
|
||||
|
||||
def test_add_home_custom(self):
|
||||
"""Should allow customizing home breadcrumb."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_home(
|
||||
label="Dashboard",
|
||||
url="/dashboard/",
|
||||
icon="fas fa-tachometer-alt",
|
||||
).build()
|
||||
|
||||
assert crumbs[0].label == "Dashboard"
|
||||
assert crumbs[0].url == "/dashboard/"
|
||||
assert crumbs[0].icon == "fas fa-tachometer-alt"
|
||||
|
||||
def test_add_breadcrumb(self):
|
||||
"""Should add breadcrumb with label and URL."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add("Parks", "/parks/").build()
|
||||
|
||||
assert len(crumbs) == 1
|
||||
assert crumbs[0].label == "Parks"
|
||||
assert crumbs[0].url == "/parks/"
|
||||
|
||||
def test_add_current(self):
|
||||
"""Should add current page breadcrumb."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_current("Current Page").build()
|
||||
|
||||
assert len(crumbs) == 1
|
||||
assert crumbs[0].label == "Current Page"
|
||||
assert crumbs[0].is_current is True
|
||||
assert crumbs[0].url is None
|
||||
|
||||
def test_add_current_with_icon(self):
|
||||
"""Should add current page with icon."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_current("Settings", icon="fas fa-cog").build()
|
||||
|
||||
assert crumbs[0].icon == "fas fa-cog"
|
||||
|
||||
def test_chain_multiple_breadcrumbs(self):
|
||||
"""Should chain multiple breadcrumbs."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = (
|
||||
builder.add_home()
|
||||
.add("Parks", "/parks/")
|
||||
.add("California", "/parks/california/")
|
||||
.add_current("Disneyland")
|
||||
.build()
|
||||
)
|
||||
|
||||
assert len(crumbs) == 4
|
||||
assert crumbs[0].label == "Home"
|
||||
assert crumbs[1].label == "Parks"
|
||||
assert crumbs[2].label == "California"
|
||||
assert crumbs[3].label == "Disneyland"
|
||||
assert crumbs[3].is_current is True
|
||||
|
||||
def test_schema_positions_auto_assigned(self):
|
||||
"""Should auto-assign schema positions."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = (
|
||||
builder.add_home().add("Parks", "/parks/").add_current("Test").build()
|
||||
)
|
||||
|
||||
assert crumbs[0].schema_position == 1
|
||||
assert crumbs[1].schema_position == 2
|
||||
assert crumbs[2].schema_position == 3
|
||||
|
||||
def test_builder_is_reusable(self):
|
||||
"""Builder should be reusable after build."""
|
||||
builder = BreadcrumbBuilder()
|
||||
builder.add_home()
|
||||
|
||||
crumbs1 = builder.build()
|
||||
builder.add("New", "/new/")
|
||||
crumbs2 = builder.build()
|
||||
|
||||
assert len(crumbs1) == 1
|
||||
assert len(crumbs2) == 2
|
||||
|
||||
|
||||
class TestBreadcrumbContextProcessor:
|
||||
"""Tests for breadcrumb context processor."""
|
||||
|
||||
def test_empty_breadcrumbs_when_not_set(self):
|
||||
"""Should return empty list when not set on request."""
|
||||
from apps.core.context_processors import breadcrumbs
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
|
||||
context = breadcrumbs(request)
|
||||
assert context["breadcrumbs"] == []
|
||||
|
||||
def test_returns_breadcrumbs_from_request(self):
|
||||
"""Should return breadcrumbs when set on request."""
|
||||
from apps.core.context_processors import breadcrumbs
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.breadcrumbs = [
|
||||
build_breadcrumb("Home", "/"),
|
||||
build_breadcrumb("Test", is_current=True),
|
||||
]
|
||||
|
||||
context = breadcrumbs(request)
|
||||
assert len(context["breadcrumbs"]) == 2
|
||||
357
backend/tests/ux/test_components.py
Normal file
357
backend/tests/ux/test_components.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Tests for UX component templates.
|
||||
|
||||
These tests verify that component templates render correctly
|
||||
with various parameter combinations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.template import Context, Template
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPageHeaderComponent:
|
||||
"""Tests for page_header.html component."""
|
||||
|
||||
def test_renders_title(self):
|
||||
"""Should render title text."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with title='Test Title' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Test Title" in html
|
||||
|
||||
def test_renders_subtitle(self):
|
||||
"""Should render subtitle when provided."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Title'
|
||||
subtitle='Subtitle text'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Subtitle text" in html
|
||||
|
||||
def test_renders_icon(self):
|
||||
"""Should render icon when provided."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Title'
|
||||
icon='fas fa-star'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "fas fa-star" in html
|
||||
|
||||
def test_renders_primary_action(self):
|
||||
"""Should render primary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Title'
|
||||
primary_action_url='/create/'
|
||||
primary_action_text='Create'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Create" in html
|
||||
assert "/create/" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestActionBarComponent:
|
||||
"""Tests for action_bar.html component."""
|
||||
|
||||
def test_renders_primary_action(self):
|
||||
"""Should render primary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
primary_action_text='Save'
|
||||
primary_action_url='/save/'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Save" in html
|
||||
assert "/save/" in html
|
||||
|
||||
def test_renders_secondary_action(self):
|
||||
"""Should render secondary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
secondary_action_text='Preview'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Preview" in html
|
||||
|
||||
def test_renders_tertiary_action(self):
|
||||
"""Should render tertiary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
tertiary_action_text='Cancel'
|
||||
tertiary_action_url='/back/'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Cancel" in html
|
||||
assert "/back/" in html
|
||||
|
||||
def test_alignment_classes(self):
|
||||
"""Should apply correct alignment classes."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
align='between'
|
||||
primary_action_text='Save'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "justify-between" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSkeletonComponents:
|
||||
"""Tests for skeleton screen components."""
|
||||
|
||||
def test_list_skeleton_renders(self):
|
||||
"""Should render list skeleton with specified rows."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/list_skeleton.html' with rows=3 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_card_grid_skeleton_renders(self):
|
||||
"""Should render card grid skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=4 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_detail_skeleton_renders(self):
|
||||
"""Should render detail skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/detail_skeleton.html' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_form_skeleton_renders(self):
|
||||
"""Should render form skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/form_skeleton.html' with fields=3 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_table_skeleton_renders(self):
|
||||
"""Should render table skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/table_skeleton.html' with rows=5 columns=4 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestModalComponents:
|
||||
"""Tests for modal components."""
|
||||
|
||||
def test_modal_base_renders(self):
|
||||
"""Should render modal base structure."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/modals/modal_base.html' with
|
||||
modal_id='test-modal'
|
||||
show_var='showModal'
|
||||
title='Test Modal'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "test-modal" in html
|
||||
assert "Test Modal" in html
|
||||
assert "showModal" in html
|
||||
|
||||
def test_modal_confirm_renders(self):
|
||||
"""Should render confirmation modal."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='confirm-modal'
|
||||
show_var='showConfirm'
|
||||
title='Confirm Action'
|
||||
message='Are you sure?'
|
||||
confirm_text='Yes'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "confirm-modal" in html
|
||||
assert "Confirm Action" in html
|
||||
assert "Are you sure?" in html
|
||||
assert "Yes" in html
|
||||
|
||||
def test_modal_confirm_destructive_variant(self):
|
||||
"""Should apply destructive styling."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='delete-modal'
|
||||
show_var='showDelete'
|
||||
title='Delete'
|
||||
message='Delete this item?'
|
||||
confirm_variant='destructive'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "btn-destructive" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBreadcrumbComponent:
|
||||
"""Tests for breadcrumb component."""
|
||||
|
||||
def test_renders_breadcrumbs(self):
|
||||
"""Should render breadcrumb navigation."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
"""
|
||||
)
|
||||
breadcrumbs = [
|
||||
{"label": "Home", "url": "/", "is_current": False},
|
||||
{"label": "Parks", "url": "/parks/", "is_current": False},
|
||||
{"label": "Test Park", "url": None, "is_current": True},
|
||||
]
|
||||
html = template.render(Context({"breadcrumbs": breadcrumbs}))
|
||||
|
||||
assert "Home" in html
|
||||
assert "Parks" in html
|
||||
assert "Test Park" in html
|
||||
|
||||
def test_renders_schema_org_markup(self):
|
||||
"""Should include Schema.org BreadcrumbList."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
"""
|
||||
)
|
||||
breadcrumbs = [
|
||||
{"label": "Home", "url": "/", "is_current": False, "schema_position": 1},
|
||||
{"label": "Test", "url": None, "is_current": True, "schema_position": 2},
|
||||
]
|
||||
html = template.render(Context({"breadcrumbs": breadcrumbs}))
|
||||
|
||||
assert "BreadcrumbList" in html
|
||||
|
||||
def test_empty_breadcrumbs(self):
|
||||
"""Should handle empty breadcrumbs gracefully."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({"breadcrumbs": []}))
|
||||
|
||||
# Should not error, may render nothing or empty nav
|
||||
assert html is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestStatusBadgeComponent:
|
||||
"""Tests for status badge component."""
|
||||
|
||||
def test_renders_status_text(self):
|
||||
"""Should render status label."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/status_badge.html' with status='published' label='Published' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Published" in html
|
||||
|
||||
def test_applies_status_colors(self):
|
||||
"""Should apply appropriate color classes for status."""
|
||||
# Test published/active status
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/status_badge.html' with status='published' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
# Should have some indication of success/green styling
|
||||
assert "green" in html.lower() or "success" in html.lower() or "published" in html.lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLoadingIndicatorComponent:
|
||||
"""Tests for loading indicator component."""
|
||||
|
||||
def test_renders_loading_indicator(self):
|
||||
"""Should render loading indicator."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'htmx/components/loading_indicator.html' with text='Loading...' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Loading" in html
|
||||
|
||||
def test_renders_with_id(self):
|
||||
"""Should render with specified ID for htmx-indicator."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'htmx/components/loading_indicator.html' with id='my-loader' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "my-loader" in html
|
||||
282
backend/tests/ux/test_htmx_utils.py
Normal file
282
backend/tests/ux/test_htmx_utils.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Tests for HTMX utility functions.
|
||||
|
||||
These tests verify that the HTMX response helpers generate
|
||||
correct responses with proper headers and content.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory
|
||||
|
||||
from apps.core.htmx_utils import (
|
||||
get_htmx_target,
|
||||
get_htmx_trigger,
|
||||
htmx_error,
|
||||
htmx_modal_close,
|
||||
htmx_redirect,
|
||||
htmx_refresh,
|
||||
htmx_refresh_section,
|
||||
htmx_success,
|
||||
htmx_trigger,
|
||||
htmx_validation_response,
|
||||
htmx_warning,
|
||||
is_htmx_request,
|
||||
)
|
||||
|
||||
|
||||
class TestIsHtmxRequest:
|
||||
"""Tests for is_htmx_request function."""
|
||||
|
||||
def test_returns_true_for_htmx_request(self):
|
||||
"""Should return True when HX-Request header is 'true'."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_REQUEST="true")
|
||||
assert is_htmx_request(request) is True
|
||||
|
||||
def test_returns_false_for_regular_request(self):
|
||||
"""Should return False for regular requests without HTMX header."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
assert is_htmx_request(request) is False
|
||||
|
||||
def test_returns_false_for_wrong_value(self):
|
||||
"""Should return False when HX-Request header has wrong value."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_REQUEST="false")
|
||||
assert is_htmx_request(request) is False
|
||||
|
||||
|
||||
class TestGetHtmxTarget:
|
||||
"""Tests for get_htmx_target function."""
|
||||
|
||||
def test_returns_target_when_present(self):
|
||||
"""Should return target ID when HX-Target header is present."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_TARGET="my-target")
|
||||
assert get_htmx_target(request) == "my-target"
|
||||
|
||||
def test_returns_none_when_missing(self):
|
||||
"""Should return None when HX-Target header is missing."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
assert get_htmx_target(request) is None
|
||||
|
||||
|
||||
class TestGetHtmxTrigger:
|
||||
"""Tests for get_htmx_trigger function."""
|
||||
|
||||
def test_returns_trigger_when_present(self):
|
||||
"""Should return trigger ID when HX-Trigger header is present."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_TRIGGER="my-button")
|
||||
assert get_htmx_trigger(request) == "my-button"
|
||||
|
||||
def test_returns_none_when_missing(self):
|
||||
"""Should return None when HX-Trigger header is missing."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
assert get_htmx_trigger(request) is None
|
||||
|
||||
|
||||
class TestHtmxRedirect:
|
||||
"""Tests for htmx_redirect function."""
|
||||
|
||||
def test_sets_redirect_header(self):
|
||||
"""Should set HX-Redirect header with correct URL."""
|
||||
response = htmx_redirect("/parks/")
|
||||
assert response["HX-Redirect"] == "/parks/"
|
||||
|
||||
def test_returns_empty_body(self):
|
||||
"""Should return empty response body."""
|
||||
response = htmx_redirect("/parks/")
|
||||
assert response.content == b""
|
||||
|
||||
|
||||
class TestHtmxTrigger:
|
||||
"""Tests for htmx_trigger function."""
|
||||
|
||||
def test_simple_trigger(self):
|
||||
"""Should set simple trigger name."""
|
||||
response = htmx_trigger("myEvent")
|
||||
assert response["HX-Trigger"] == "myEvent"
|
||||
|
||||
def test_trigger_with_payload(self):
|
||||
"""Should set trigger with JSON payload."""
|
||||
response = htmx_trigger("myEvent", {"key": "value"})
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
assert trigger_data == {"myEvent": {"key": "value"}}
|
||||
|
||||
|
||||
class TestHtmxRefresh:
|
||||
"""Tests for htmx_refresh function."""
|
||||
|
||||
def test_sets_refresh_header(self):
|
||||
"""Should set HX-Refresh header to 'true'."""
|
||||
response = htmx_refresh()
|
||||
assert response["HX-Refresh"] == "true"
|
||||
|
||||
|
||||
class TestHtmxSuccess:
|
||||
"""Tests for htmx_success function."""
|
||||
|
||||
def test_basic_success_message(self):
|
||||
"""Should create success response with toast trigger."""
|
||||
response = htmx_success("Item saved!")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert "showToast" in trigger_data
|
||||
assert trigger_data["showToast"]["type"] == "success"
|
||||
assert trigger_data["showToast"]["message"] == "Item saved!"
|
||||
assert trigger_data["showToast"]["duration"] == 5000
|
||||
|
||||
def test_success_with_custom_duration(self):
|
||||
"""Should allow custom duration."""
|
||||
response = htmx_success("Quick message", duration=2000)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["duration"] == 2000
|
||||
|
||||
def test_success_with_title(self):
|
||||
"""Should include title when provided."""
|
||||
response = htmx_success("Details here", title="Success!")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["title"] == "Success!"
|
||||
|
||||
def test_success_with_action(self):
|
||||
"""Should include action button config."""
|
||||
response = htmx_success(
|
||||
"Item deleted",
|
||||
action={"label": "Undo", "onClick": "undoDelete()"},
|
||||
)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["action"]["label"] == "Undo"
|
||||
assert trigger_data["showToast"]["action"]["onClick"] == "undoDelete()"
|
||||
|
||||
def test_success_with_html_content(self):
|
||||
"""Should include HTML in response body."""
|
||||
response = htmx_success("Done", html="<div>Updated</div>")
|
||||
assert response.content == b"<div>Updated</div>"
|
||||
|
||||
|
||||
class TestHtmxError:
|
||||
"""Tests for htmx_error function."""
|
||||
|
||||
def test_basic_error_message(self):
|
||||
"""Should create error response with toast trigger."""
|
||||
response = htmx_error("Something went wrong")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert response.status_code == 400
|
||||
assert trigger_data["showToast"]["type"] == "error"
|
||||
assert trigger_data["showToast"]["message"] == "Something went wrong"
|
||||
assert trigger_data["showToast"]["duration"] == 0 # Persistent by default
|
||||
|
||||
def test_error_with_custom_status(self):
|
||||
"""Should allow custom HTTP status code."""
|
||||
response = htmx_error("Validation failed", status=422)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_error_with_retry_action(self):
|
||||
"""Should include retry action when requested."""
|
||||
response = htmx_error("Server error", show_retry=True)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["action"]["label"] == "Retry"
|
||||
|
||||
|
||||
class TestHtmxWarning:
|
||||
"""Tests for htmx_warning function."""
|
||||
|
||||
def test_basic_warning_message(self):
|
||||
"""Should create warning response with toast trigger."""
|
||||
response = htmx_warning("Session expiring soon")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["type"] == "warning"
|
||||
assert trigger_data["showToast"]["message"] == "Session expiring soon"
|
||||
assert trigger_data["showToast"]["duration"] == 8000
|
||||
|
||||
|
||||
class TestHtmxModalClose:
|
||||
"""Tests for htmx_modal_close function."""
|
||||
|
||||
def test_basic_modal_close(self):
|
||||
"""Should trigger closeModal event."""
|
||||
response = htmx_modal_close()
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["closeModal"] is True
|
||||
|
||||
def test_modal_close_with_message(self):
|
||||
"""Should include success toast when message provided."""
|
||||
response = htmx_modal_close(message="Saved successfully!")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["closeModal"] is True
|
||||
assert trigger_data["showToast"]["message"] == "Saved successfully!"
|
||||
|
||||
def test_modal_close_with_refresh(self):
|
||||
"""Should include refresh section trigger."""
|
||||
response = htmx_modal_close(
|
||||
message="Done",
|
||||
refresh_target="#items-list",
|
||||
refresh_url="/items/",
|
||||
)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["refreshSection"]["target"] == "#items-list"
|
||||
assert trigger_data["refreshSection"]["url"] == "/items/"
|
||||
|
||||
|
||||
class TestHtmxRefreshSection:
|
||||
"""Tests for htmx_refresh_section function."""
|
||||
|
||||
def test_sets_retarget_header(self):
|
||||
"""Should set HX-Retarget header."""
|
||||
response = htmx_refresh_section("#my-section", html="<div>New</div>")
|
||||
assert response["HX-Retarget"] == "#my-section"
|
||||
assert response["HX-Reswap"] == "innerHTML"
|
||||
|
||||
def test_includes_success_message(self):
|
||||
"""Should include toast when message provided."""
|
||||
response = htmx_refresh_section(
|
||||
"#my-section",
|
||||
html="<div>New</div>",
|
||||
message="Section updated",
|
||||
)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["message"] == "Section updated"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHtmxValidationResponse:
|
||||
"""Tests for htmx_validation_response function."""
|
||||
|
||||
def test_validation_error_response(self):
|
||||
"""Should render error template with errors."""
|
||||
response = htmx_validation_response(
|
||||
"email",
|
||||
errors=["Invalid email format"],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Response should contain error markup
|
||||
|
||||
def test_validation_success_response(self):
|
||||
"""Should render success template with message."""
|
||||
response = htmx_validation_response(
|
||||
"username",
|
||||
success_message="Username available",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Response should contain success markup
|
||||
|
||||
def test_validation_neutral_response(self):
|
||||
"""Should render empty success when no errors or message."""
|
||||
response = htmx_validation_response("field")
|
||||
assert response.status_code == 200
|
||||
139
backend/tests/ux/test_messages.py
Normal file
139
backend/tests/ux/test_messages.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for standardized message utilities.
|
||||
|
||||
These tests verify that message helper functions generate
|
||||
consistent, user-friendly messages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.core.utils.messages import (
|
||||
confirm_delete,
|
||||
error_not_found,
|
||||
error_permission,
|
||||
error_validation,
|
||||
info_no_changes,
|
||||
success_created,
|
||||
success_deleted,
|
||||
success_updated,
|
||||
warning_unsaved,
|
||||
)
|
||||
|
||||
|
||||
class TestSuccessMessages:
|
||||
"""Tests for success message helpers."""
|
||||
|
||||
def test_success_created_basic(self):
|
||||
"""Should generate basic created message."""
|
||||
message = success_created("Park")
|
||||
assert "Park" in message
|
||||
assert "created" in message.lower()
|
||||
|
||||
def test_success_created_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = success_created("Park", "Disneyland")
|
||||
assert "Disneyland" in message
|
||||
|
||||
def test_success_created_custom(self):
|
||||
"""Should use custom message when provided."""
|
||||
message = success_created("Park", custom_message="Your park is ready!")
|
||||
assert message == "Your park is ready!"
|
||||
|
||||
def test_success_updated_basic(self):
|
||||
"""Should generate basic updated message."""
|
||||
message = success_updated("Park")
|
||||
assert "Park" in message
|
||||
assert "updated" in message.lower()
|
||||
|
||||
def test_success_updated_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = success_updated("Park", "Disneyland")
|
||||
assert "Disneyland" in message
|
||||
|
||||
def test_success_deleted_basic(self):
|
||||
"""Should generate basic deleted message."""
|
||||
message = success_deleted("Park")
|
||||
assert "Park" in message
|
||||
assert "deleted" in message.lower()
|
||||
|
||||
def test_success_deleted_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = success_deleted("Park", "Old Park")
|
||||
assert "Old Park" in message
|
||||
|
||||
|
||||
class TestErrorMessages:
|
||||
"""Tests for error message helpers."""
|
||||
|
||||
def test_error_validation_generic(self):
|
||||
"""Should generate generic validation error."""
|
||||
message = error_validation()
|
||||
assert "validation" in message.lower() or "invalid" in message.lower()
|
||||
|
||||
def test_error_validation_with_field(self):
|
||||
"""Should include field name when provided."""
|
||||
message = error_validation("email")
|
||||
assert "email" in message.lower()
|
||||
|
||||
def test_error_validation_custom(self):
|
||||
"""Should use custom message when provided."""
|
||||
message = error_validation(custom_message="Email format is invalid")
|
||||
assert message == "Email format is invalid"
|
||||
|
||||
def test_error_not_found_basic(self):
|
||||
"""Should generate not found message."""
|
||||
message = error_not_found("Park")
|
||||
assert "Park" in message
|
||||
assert "not found" in message.lower() or "could not" in message.lower()
|
||||
|
||||
def test_error_permission_basic(self):
|
||||
"""Should generate permission denied message."""
|
||||
message = error_permission()
|
||||
assert "permission" in message.lower() or "authorized" in message.lower()
|
||||
|
||||
def test_error_permission_with_action(self):
|
||||
"""Should include action when provided."""
|
||||
message = error_permission("delete this park")
|
||||
assert "delete" in message.lower()
|
||||
|
||||
|
||||
class TestWarningMessages:
|
||||
"""Tests for warning message helpers."""
|
||||
|
||||
def test_warning_unsaved(self):
|
||||
"""Should generate unsaved changes warning."""
|
||||
message = warning_unsaved()
|
||||
assert "unsaved" in message.lower() or "changes" in message.lower()
|
||||
|
||||
|
||||
class TestInfoMessages:
|
||||
"""Tests for info message helpers."""
|
||||
|
||||
def test_info_no_changes(self):
|
||||
"""Should generate no changes message."""
|
||||
message = info_no_changes()
|
||||
assert "no changes" in message.lower() or "nothing" in message.lower()
|
||||
|
||||
|
||||
class TestConfirmMessages:
|
||||
"""Tests for confirmation message helpers."""
|
||||
|
||||
def test_confirm_delete_basic(self):
|
||||
"""Should generate delete confirmation message."""
|
||||
message = confirm_delete("Park")
|
||||
assert "Park" in message
|
||||
assert "delete" in message.lower()
|
||||
|
||||
def test_confirm_delete_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = confirm_delete("Park", "Disneyland")
|
||||
assert "Disneyland" in message
|
||||
|
||||
def test_confirm_delete_warning(self):
|
||||
"""Should include warning about irreversibility."""
|
||||
message = confirm_delete("Park")
|
||||
assert (
|
||||
"cannot be undone" in message.lower()
|
||||
or "permanent" in message.lower()
|
||||
or "sure" in message.lower()
|
||||
)
|
||||
203
backend/tests/ux/test_meta.py
Normal file
203
backend/tests/ux/test_meta.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Tests for meta tag utilities.
|
||||
|
||||
These tests verify that meta tag helpers generate
|
||||
correct SEO and social sharing metadata.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
|
||||
from apps.core.utils.meta import (
|
||||
build_canonical_url,
|
||||
build_meta_context,
|
||||
generate_meta_description,
|
||||
get_og_image,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateMetaDescription:
|
||||
"""Tests for generate_meta_description function."""
|
||||
|
||||
def test_basic_text(self):
|
||||
"""Should return text as description."""
|
||||
description = generate_meta_description(text="This is a test description.")
|
||||
assert description == "This is a test description."
|
||||
|
||||
def test_truncates_long_text(self):
|
||||
"""Should truncate text longer than max_length."""
|
||||
long_text = "A" * 200
|
||||
description = generate_meta_description(text=long_text, max_length=160)
|
||||
assert len(description) <= 160
|
||||
assert description.endswith("...")
|
||||
|
||||
def test_custom_max_length(self):
|
||||
"""Should respect custom max_length."""
|
||||
text = "A" * 100
|
||||
description = generate_meta_description(text=text, max_length=50)
|
||||
assert len(description) <= 50
|
||||
|
||||
def test_strips_html(self):
|
||||
"""Should strip HTML tags from text."""
|
||||
html_text = "<p>This is <strong>bold</strong> text.</p>"
|
||||
description = generate_meta_description(text=html_text)
|
||||
assert "<p>" not in description
|
||||
assert "<strong>" not in description
|
||||
assert "bold" in description
|
||||
|
||||
def test_handles_none(self):
|
||||
"""Should return empty string for None input."""
|
||||
description = generate_meta_description(text=None)
|
||||
assert description == ""
|
||||
|
||||
def test_handles_empty_string(self):
|
||||
"""Should return empty string for empty input."""
|
||||
description = generate_meta_description(text="")
|
||||
assert description == ""
|
||||
|
||||
|
||||
class TestGetOgImage:
|
||||
"""Tests for get_og_image function."""
|
||||
|
||||
def test_returns_image_url(self):
|
||||
"""Should return provided image URL."""
|
||||
url = get_og_image(image_url="https://example.com/image.jpg")
|
||||
assert url == "https://example.com/image.jpg"
|
||||
|
||||
def test_returns_default_when_none(self):
|
||||
"""Should return default OG image when no image provided."""
|
||||
url = get_og_image()
|
||||
# Should return some default or empty string
|
||||
assert url is not None
|
||||
|
||||
def test_makes_url_absolute(self):
|
||||
"""Should convert relative URL to absolute when request provided."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.META["HTTP_HOST"] = "example.com"
|
||||
|
||||
url = get_og_image(image_url="/static/images/og.jpg", request=request)
|
||||
assert "example.com" in url or url.startswith("/")
|
||||
|
||||
|
||||
class TestBuildCanonicalUrl:
|
||||
"""Tests for build_canonical_url function."""
|
||||
|
||||
def test_returns_path(self):
|
||||
"""Should return provided path."""
|
||||
url = build_canonical_url(path="/parks/test/")
|
||||
assert "/parks/test/" in url
|
||||
|
||||
def test_builds_from_request(self):
|
||||
"""Should build URL from request."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/parks/")
|
||||
request.META["HTTP_HOST"] = "example.com"
|
||||
|
||||
url = build_canonical_url(request=request)
|
||||
assert "/parks/" in url
|
||||
|
||||
def test_handles_none(self):
|
||||
"""Should return empty string when nothing provided."""
|
||||
url = build_canonical_url()
|
||||
assert url == "" or url is not None
|
||||
|
||||
|
||||
class TestBuildMetaContext:
|
||||
"""Tests for build_meta_context function."""
|
||||
|
||||
def test_basic_meta_context(self):
|
||||
"""Should build basic meta context with title."""
|
||||
context = build_meta_context(title="Test Page")
|
||||
|
||||
assert "title" in context
|
||||
assert context["title"] == "Test Page"
|
||||
|
||||
def test_includes_description(self):
|
||||
"""Should include description when provided."""
|
||||
context = build_meta_context(
|
||||
title="Test Page",
|
||||
description="This is a test page description.",
|
||||
)
|
||||
|
||||
assert "description" in context
|
||||
assert context["description"] == "This is a test page description."
|
||||
|
||||
def test_includes_og_tags(self):
|
||||
"""Should include Open Graph tags."""
|
||||
context = build_meta_context(
|
||||
title="Test Page",
|
||||
description="Description here.",
|
||||
)
|
||||
|
||||
assert "og_title" in context or "title" in context
|
||||
assert "og_description" in context or "description" in context
|
||||
|
||||
def test_includes_canonical_url(self):
|
||||
"""Should include canonical URL when request provided."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/test/")
|
||||
request.META["HTTP_HOST"] = "example.com"
|
||||
|
||||
context = build_meta_context(
|
||||
title="Test",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert "canonical_url" in context
|
||||
|
||||
def test_includes_og_image(self):
|
||||
"""Should include OG image when provided."""
|
||||
context = build_meta_context(
|
||||
title="Test",
|
||||
og_image="https://example.com/image.jpg",
|
||||
)
|
||||
|
||||
assert "og_image" in context
|
||||
assert context["og_image"] == "https://example.com/image.jpg"
|
||||
|
||||
def test_includes_og_type(self):
|
||||
"""Should include OG type."""
|
||||
context = build_meta_context(
|
||||
title="Test",
|
||||
og_type="article",
|
||||
)
|
||||
|
||||
assert "og_type" in context
|
||||
assert context["og_type"] == "article"
|
||||
|
||||
def test_default_og_type(self):
|
||||
"""Should default to 'website' for OG type."""
|
||||
context = build_meta_context(title="Test")
|
||||
|
||||
if "og_type" in context:
|
||||
assert context["og_type"] == "website"
|
||||
|
||||
|
||||
class TestMetaContextProcessor:
|
||||
"""Tests for page_meta context processor."""
|
||||
|
||||
def test_empty_meta_when_not_set(self):
|
||||
"""Should return empty dict when not set on request."""
|
||||
from apps.core.context_processors import page_meta
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
|
||||
context = page_meta(request)
|
||||
assert context["page_meta"] == {}
|
||||
|
||||
def test_returns_meta_from_request(self):
|
||||
"""Should return page_meta when set on request."""
|
||||
from apps.core.context_processors import page_meta
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.page_meta = {
|
||||
"title": "Test Page",
|
||||
"description": "Test description",
|
||||
}
|
||||
|
||||
context = page_meta(request)
|
||||
assert context["page_meta"]["title"] == "Test Page"
|
||||
assert context["page_meta"]["description"] == "Test description"
|
||||
Reference in New Issue
Block a user