Source code for htmx_nav.testing

"""Testing utilities for django-htmx-nav.

Provides assertion helpers for Django and HTMX test suites to verify
navigation parity and HTML fragment composition.
"""

import difflib
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from django.test import Client
from django.test.html import parse_html

if TYPE_CHECKING:
    from bs4 import BeautifulSoup, Tag
else:
    try:
        from bs4 import BeautifulSoup, Tag
    except ImportError:
        BeautifulSoup = None
        Tag = None

__all__ = ["assert_shell_parity", "assert_shell_composition", "assert_html_equal"]


_DEBUG_MARKER_RE = re.compile(
    r"<script>\(function\(\)\{.*?hn-swap.*?\}\)\(\);</script>", re.DOTALL
)


def _strip_debug_markers(html: str) -> str:
    """Remove debug-swap marker <script> tags from HTML for comparison."""
    return _DEBUG_MARKER_RE.sub("", html)


[docs] def assert_shell_parity( client: Client, url: str, *, requests: dict[str, dict[str, Any]], checks: dict[str, Callable[[Any], Any]], ) -> dict[str, Any]: """Assert that template context remains consistent across request modes. Issues GET requests for each scenario in ``requests`` and executes extraction callbacks against response contexts to verify shell state parity (e.g. active links, breadcrumbs, sidebar items). Args: client: The Django test client instance. url: The target URL to request. requests: Mapping of scenario labels to kwargs passed to ``client.get``. checks: Mapping of check labels to extraction functions receiving ``response.context``. Returns: Mapping of scenario labels to their Django response objects. Raises: AssertionError: If any check produces a value that differs from the baseline established by the first request. Example: .. code-block:: python requests = { "full_reload": {}, "page_shell": {"HTTP_HX_REQUEST": "true", "HTTP_HX_TARGET": "page-content"}, } checks = { "active_tab": lambda ctx: ctx["active_tab"], "breadcrumbs": lambda ctx: [c["label"] for c in ctx["nav"]["breadcrumbs"]], } responses = assert_shell_parity( client, "/dashboard/", requests=requests, checks=checks ) """ responses = {label: client.get(url, **kwargs) for label, kwargs in requests.items()} for check_label, extract in checks.items(): values = {label: extract(resp.context) for label, resp in responses.items()} baseline_label, baseline_value = next(iter(values.items())) for label, value in values.items(): assert value == baseline_value, ( f"Shell parity broken for check {check_label!r} at {url!r}: " f"{baseline_label!r} gave {baseline_value!r}, {label!r} gave {value!r}." ) return responses
[docs] def assert_html_equal( a: bytes | str, b: bytes | str, *, label_a: str = "a", label_b: str = "b" ) -> None: """Assert that two HTML documents or fragments are structurally equal. Normalizes whitespace and attribute ordering using Django's ``parse_html`` and strips debug marker scripts before comparing. Args: a: First HTML document or fragment. b: Second HTML document or fragment. label_a: Label for ``a`` in unified diff output. Defaults to "a". label_b: Label for ``b`` in unified diff output. Defaults to "b". Raises: AssertionError: If the two documents differ structurally, including a unified diff. Example: .. code-block:: python assert_html_equal(response.content, "<div id='main'>Hello</div>") """ if isinstance(a, bytes): a = a.decode("utf-8") if isinstance(b, bytes): b = b.decode("utf-8") a_parsed = str(parse_html(_strip_debug_markers(a))) b_parsed = str(parse_html(_strip_debug_markers(b))) if a_parsed == b_parsed: return diff = "\n".join( difflib.unified_diff( a_parsed.splitlines(), b_parsed.splitlines(), lineterm="", fromfile=label_a, tofile=label_b, ) ) raise AssertionError(f"HTML mismatch between {label_a} and {label_b}:\n{diff}")
class _HTMLDocument: """Helper for parsing response HTML and extracting DOM fragments.""" def __init__(self, raw_html: bytes | str): if BeautifulSoup is None or Tag is None: raise ImportError( "assert_shell_composition requires beautifulsoup4. " "Install it with: pip install beautifulsoup4" ) if isinstance(raw_html, bytes): raw_html = raw_html.decode("utf-8") self.soup = BeautifulSoup(_strip_debug_markers(raw_html), "html.parser") def _find_element(self, element_id: str) -> Tag: element = self.soup.find(id=element_id) if element is None or not isinstance(element, Tag): raise AssertionError( f"Could not find any element with id={element_id!r} in the response HTML." ) return element def inner_html(self, element_id: str) -> str: """Return the serialized child nodes of the element.""" return str(self._find_element(element_id).decode_contents()) def outer_html(self, element_id: str) -> str: """Return the serialized element including its opening and closing tags.""" return str(self._find_element(element_id)) def container_html(self, element_id: str, *, self_wrapped: bool) -> str: """Return outer HTML if self_wrapped is True, otherwise inner HTML.""" return ( self.outer_html(element_id) if self_wrapped else self.inner_html(element_id) ) def split_fragments(self) -> tuple[str, dict[str, str]]: """Split the document into primary content and out-of-band swap fragments.""" fragments: dict[str, str] = {} primary_parts: list[str] = [] for node in list(self.soup.contents): if not isinstance(node, Tag): primary_parts.append(str(node)) continue name = node.name attrs = node.attrs or {} if name == "title": node.extract() elif attrs.get("hx-swap-oob") and attrs.get("id"): elem_id = attrs["id"] elem_id_str = elem_id if isinstance(elem_id, str) else str(elem_id) fragments[elem_id_str] = str(node.decode_contents()) node.extract() elif name == "hx-partial" and (hx_target := attrs.get("hx-target")): hx_target_str = ( hx_target if isinstance(hx_target, str) else str(hx_target) ) target_id = hx_target_str.lstrip("#") if target_id: fragments[target_id] = str(node.decode_contents()) node.extract() else: primary_parts.append(str(node)) return "".join(primary_parts), fragments
[docs] def assert_shell_composition( client: Client, url: str, *, page_shell_kwargs: dict[str, Any], tab_shell_kwargs: dict[str, Any], full_reload_kwargs: dict[str, Any] | None = None, page_container_id: str = "page-content", tab_container_id: str = "tab-content", self_wrapped: bool = False, ) -> dict[str, Any]: """Assert that full-page reloads and HTMX swap responses compose identical HTML. Performs requests across three interaction tiers (full page reload, page-level shell swap, and tab/component swap) and verifies that HTML fragments nest and match structurally without state drift. Args: client: The Django test client instance. url: The target endpoint URL. page_shell_kwargs: Kwargs for ``client.get`` representing a page-level swap. tab_shell_kwargs: Kwargs for ``client.get`` representing a tab-level swap. full_reload_kwargs: Optional kwargs for a standard browser GET. Defaults to ``{}``. page_container_id: DOM element ID targeted by page-level swaps. Defaults to "page-content". tab_container_id: DOM element ID targeted by tab-level swaps. Defaults to "tab-content". self_wrapped: Set to True if swap responses re-emit their outer container tag (``hx-swap="outerHTML"``). Defaults to False. Returns: Mapping containing ``"full_reload"``, ``"page_shell"``, and ``"tab_shell"`` response objects. Raises: AssertionError: If any response status is not 200, a container ID is missing, or fragment markup diverges. ImportError: If ``beautifulsoup4`` is not installed. Example: .. code-block:: python responses = assert_shell_composition( client, "/projects/1/", page_shell_kwargs={"HTTP_HX_REQUEST": "true", "HTTP_HX_TARGET": "page-content"}, tab_shell_kwargs={"HTTP_HX_REQUEST": "true", "HTTP_HX_TARGET": "tab-content"}, page_container_id="page-content", tab_container_id="tab-content", ) """ full_reload_kwargs = full_reload_kwargs or {} full = client.get(url, **full_reload_kwargs) page_shell = client.get(url, **page_shell_kwargs) tab_shell = client.get(url, **tab_shell_kwargs) for label, resp in [ ("full_reload", full), ("page_shell", page_shell), ("tab_shell", tab_shell), ]: assert resp.status_code == 200, ( f"{label} request to {url!r} returned {resp.status_code}" ) full_doc = _HTMLDocument(full.content) page_doc = _HTMLDocument(page_shell.content) tab_doc = _HTMLDocument(tab_shell.content) page_primary, page_fragments = page_doc.split_fragments() tab_primary, tab_fragments = tab_doc.split_fragments() # 1. full reload <-> page_shell primary content assert_html_equal( full_doc.container_html(page_container_id, self_wrapped=self_wrapped), page_primary, label_a=f"full reload's #{page_container_id}", label_b="page_shell primary content", ) # 2. full reload <-> page_shell fragments for frag_id, frag_html in page_fragments.items(): assert_html_equal( full_doc.inner_html(frag_id), frag_html, label_a=f"full reload's #{frag_id}", label_b=f"page_shell #{frag_id} fragment", ) # 3. page_shell <-> tab_shell primary content (nesting) assert_html_equal( page_doc.container_html(tab_container_id, self_wrapped=self_wrapped), tab_primary, label_a=f"page_shell's #{tab_container_id}", label_b="tab_shell primary content", ) # 4. full reload <-> tab_shell primary content (transitive) assert_html_equal( full_doc.container_html(tab_container_id, self_wrapped=self_wrapped), tab_primary, label_a=f"full reload's #{tab_container_id}", label_b="tab_shell primary content", ) # 5. full reload <-> tab_shell fragments for frag_id, frag_html in tab_fragments.items(): assert_html_equal( full_doc.inner_html(frag_id), frag_html, label_a=f"full reload's #{frag_id}", label_b=f"tab_shell #{frag_id} fragment", ) return {"full_reload": full, "page_shell": page_shell, "tab_shell": tab_shell}