Phase 3: Client-side routing with SX page registry + routing analyzer demo
Add client-side route matching so pure pages (no IO deps) can render instantly without a server roundtrip. Page metadata serialized as SX dict literals (not JSON) in <script type="text/sx-pages"> blocks. - New router.sx spec: route pattern parsing and matching (6 pure functions) - boot.sx: process page registry using SX parser at startup - orchestration.sx: intercept boost links for client routing with try-first/fallback — client attempts local eval, falls back to server - helpers.py: _build_pages_sx() serializes defpage metadata as SX - Routing analyzer demo page showing per-page client/server classification - 32 tests for Phase 2 IO detection (scan_io_refs, transitive_io_refs, compute_all_io_refs, component_pure?) + fallback/ref parity - 37 tests for Phase 3 router functions + page registry serialization - Fix bootstrap_py.py _emit_let cell variable initialization bug - Fix missing primitive aliases (split, length, merge) in bootstrap_py.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
392
shared/sx/tests/test_io_detection.py
Normal file
392
shared/sx/tests/test_io_detection.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Tests for Phase 2 IO detection — component purity analysis.
|
||||
|
||||
Tests both the hand-written fallback (deps.py) and the bootstrapped
|
||||
sx_ref.py implementation of IO reference scanning and transitive
|
||||
IO classification.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Component, Macro, Symbol
|
||||
from shared.sx.deps import (
|
||||
_scan_io_refs_fallback,
|
||||
_transitive_io_refs_fallback,
|
||||
_compute_all_io_refs_fallback,
|
||||
compute_all_io_refs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_env(*sx_sources: str) -> dict:
|
||||
"""Parse and evaluate component definitions into an env dict."""
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
env: dict = {}
|
||||
for source in sx_sources:
|
||||
exprs = parse_all(source)
|
||||
for expr in exprs:
|
||||
_trampoline(_eval(expr, env))
|
||||
return env
|
||||
|
||||
|
||||
IO_NAMES = {"fetch-data", "call-action", "app-url", "config", "db-query"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_io_refs_fallback — scan single AST for IO primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanIoRefs:
|
||||
def test_no_io_refs(self):
|
||||
env = make_env('(defcomp ~card (&key title) (div :class "p-4" title))')
|
||||
comp = env["~card"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_direct_io_ref(self):
|
||||
env = make_env('(defcomp ~page (&key) (div (fetch-data "posts")))')
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_multiple_io_refs(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x") (config "y")))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"fetch-data", "config"}
|
||||
|
||||
def test_io_in_nested_control_flow(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key show) '
|
||||
' (if show (div (app-url "/")) (span "none")))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_io_in_dict_value(self):
|
||||
env = make_env(
|
||||
'(defcomp ~wrap (&key) (div {:data (db-query "x")}))'
|
||||
)
|
||||
comp = env["~wrap"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"db-query"}
|
||||
|
||||
def test_non_io_symbol_ignored(self):
|
||||
"""Symbols that aren't in the IO set should not be detected."""
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (str "hello") (len "world")))'
|
||||
)
|
||||
comp = env["~card"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_component_ref_not_io(self):
|
||||
"""Component references (~name) should not appear as IO refs."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~card :title "hi")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _transitive_io_refs_fallback — follow deps to find all IO refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransitiveIoRefs:
|
||||
def test_pure_component(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~card", env, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_direct_io(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "posts")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_transitive_io_through_dep(self):
|
||||
"""IO ref in a dependency should propagate to the parent."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/home")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_multiple_transitive_io(self):
|
||||
"""IO refs from multiple deps should be unioned."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~header) (~footer)))',
|
||||
'(defcomp ~header (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~footer (&key) (footer (config "site-name")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url", "config"}
|
||||
|
||||
def test_deep_transitive_io(self):
|
||||
"""IO refs should propagate through multiple levels."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~layout)))',
|
||||
'(defcomp ~layout (&key) (div (~sidebar)))',
|
||||
'(defcomp ~sidebar (&key) (nav (fetch-data "menu")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_circular_deps_no_infinite_loop(self):
|
||||
"""Circular component references should not cause infinite recursion."""
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div (~b) (app-url "/")))',
|
||||
'(defcomp ~b (&key) (div (~a)))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~a", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_without_tilde_prefix(self):
|
||||
"""Should auto-add ~ prefix when not provided."""
|
||||
env = make_env(
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_missing_dep_component(self):
|
||||
"""Referencing a component not in env should not crash."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~unknown) (fetch-data "x")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_macro_io_detection(self):
|
||||
"""IO refs in macros should be detected too."""
|
||||
env = make_env(
|
||||
'(defmacro ~with-data (body) (list (quote div) (list (quote fetch-data) "x") body))',
|
||||
'(defcomp ~page (&key) (div (~with-data (span "hi"))))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert "fetch-data" in refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_all_io_refs_fallback — batch computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeAllIoRefs:
|
||||
def test_sets_io_refs_on_components(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data", "app-url"}
|
||||
assert env["~nav"].io_refs == {"app-url"}
|
||||
assert env["~card"].io_refs == set()
|
||||
|
||||
def test_pure_components_get_empty_set(self):
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span "world"))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~a"].io_refs == set()
|
||||
assert env["~b"].io_refs == set()
|
||||
|
||||
def test_transitive_io_via_compute_all(self):
|
||||
"""Transitive IO refs should be cached on the parent component."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~child)))',
|
||||
'(defcomp ~child (&key) (div (config "key")))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"config"}
|
||||
assert env["~child"].io_refs == {"config"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API dispatch — compute_all_io_refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublicApiIoRefs:
|
||||
def test_fallback_mode(self):
|
||||
"""Public API should work in fallback mode (SX_USE_REF != 1)."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x")))',
|
||||
'(defcomp ~leaf (&key) (span "pure"))',
|
||||
)
|
||||
old_val = os.environ.get("SX_USE_REF")
|
||||
try:
|
||||
os.environ.pop("SX_USE_REF", None)
|
||||
compute_all_io_refs(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data"}
|
||||
assert env["~leaf"].io_refs == set()
|
||||
finally:
|
||||
if old_val is not None:
|
||||
os.environ["SX_USE_REF"] = old_val
|
||||
|
||||
def test_ref_mode(self):
|
||||
"""Public API should work with bootstrapped sx_ref.py (SX_USE_REF=1)."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x")))',
|
||||
'(defcomp ~leaf (&key) (span "pure"))',
|
||||
)
|
||||
old_val = os.environ.get("SX_USE_REF")
|
||||
try:
|
||||
os.environ["SX_USE_REF"] = "1"
|
||||
compute_all_io_refs(env, IO_NAMES)
|
||||
# sx_ref returns lists, compute_all_io_refs converts as needed
|
||||
page_refs = env["~page"].io_refs
|
||||
leaf_refs = env["~leaf"].io_refs
|
||||
# May be list or set depending on backend
|
||||
assert "fetch-data" in page_refs
|
||||
assert len(leaf_refs) == 0
|
||||
finally:
|
||||
if old_val is not None:
|
||||
os.environ["SX_USE_REF"] = old_val
|
||||
else:
|
||||
os.environ.pop("SX_USE_REF", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrapped sx_ref.py IO functions — direct testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxRefIoFunctions:
|
||||
"""Test the bootstrapped sx_ref.py IO functions directly."""
|
||||
|
||||
def test_scan_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import scan_io_refs
|
||||
env = make_env('(defcomp ~page (&key) (div (fetch-data "x") (config "y")))')
|
||||
comp = env["~page"]
|
||||
refs = scan_io_refs(comp.body, list(IO_NAMES))
|
||||
assert set(refs) == {"fetch-data", "config"}
|
||||
|
||||
def test_scan_io_refs_no_match(self):
|
||||
from shared.sx.ref.sx_ref import scan_io_refs
|
||||
env = make_env('(defcomp ~card (&key title) (div title))')
|
||||
comp = env["~card"]
|
||||
refs = scan_io_refs(comp.body, list(IO_NAMES))
|
||||
assert refs == []
|
||||
|
||||
def test_transitive_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = transitive_io_refs("~page", env, list(IO_NAMES))
|
||||
assert set(refs) == {"app-url"}
|
||||
|
||||
def test_transitive_io_refs_pure(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env('(defcomp ~card (&key) (div "hi"))')
|
||||
refs = transitive_io_refs("~card", env, list(IO_NAMES))
|
||||
assert refs == []
|
||||
|
||||
def test_compute_all_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key) (div "pure"))',
|
||||
)
|
||||
ref_compute(env, list(IO_NAMES))
|
||||
page_refs = env["~page"].io_refs
|
||||
nav_refs = env["~nav"].io_refs
|
||||
card_refs = env["~card"].io_refs
|
||||
assert "fetch-data" in page_refs
|
||||
assert "app-url" in page_refs
|
||||
assert "app-url" in nav_refs
|
||||
assert len(card_refs) == 0
|
||||
|
||||
def test_component_pure_p(self):
|
||||
from shared.sx.ref.sx_ref import component_pure_p
|
||||
env = make_env(
|
||||
'(defcomp ~pure-card (&key) (div "hello"))',
|
||||
'(defcomp ~io-card (&key) (div (fetch-data "x")))',
|
||||
)
|
||||
io_list = list(IO_NAMES)
|
||||
assert component_pure_p("~pure-card", env, io_list) is True
|
||||
assert component_pure_p("~io-card", env, io_list) is False
|
||||
|
||||
def test_component_pure_p_transitive(self):
|
||||
"""A component is impure if any transitive dep uses IO."""
|
||||
from shared.sx.ref.sx_ref import component_pure_p
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~child)))',
|
||||
'(defcomp ~child (&key) (div (config "key")))',
|
||||
)
|
||||
io_list = list(IO_NAMES)
|
||||
assert component_pure_p("~page", env, io_list) is False
|
||||
assert component_pure_p("~child", env, io_list) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parity: fallback vs bootstrapped produce same results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFallbackVsRefParity:
|
||||
"""Ensure fallback Python and bootstrapped sx_ref.py agree."""
|
||||
|
||||
def _check_parity(self, *sx_sources: str):
|
||||
"""Run both implementations and verify io_refs match."""
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
|
||||
# Run fallback
|
||||
env_fb = make_env(*sx_sources)
|
||||
_compute_all_io_refs_fallback(env_fb, IO_NAMES)
|
||||
|
||||
# Run bootstrapped
|
||||
env_ref = make_env(*sx_sources)
|
||||
ref_compute(env_ref, list(IO_NAMES))
|
||||
|
||||
# Compare all components
|
||||
for key in env_fb:
|
||||
if isinstance(env_fb[key], Component):
|
||||
fb_refs = env_fb[key].io_refs or set()
|
||||
ref_refs = env_ref[key].io_refs
|
||||
# Normalize: fallback returns set, ref returns list/set
|
||||
assert set(fb_refs) == set(ref_refs), (
|
||||
f"Mismatch for {key}: fallback={fb_refs}, ref={set(ref_refs)}"
|
||||
)
|
||||
|
||||
def test_parity_pure_components(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span (~a)))',
|
||||
)
|
||||
|
||||
def test_parity_io_components(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~page (&key) (div (~header) (fetch-data "x")))',
|
||||
'(defcomp ~header (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~footer (&key) (footer "static"))',
|
||||
)
|
||||
|
||||
def test_parity_deep_chain(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~a (&key) (div (~b)))',
|
||||
'(defcomp ~b (&key) (div (~c)))',
|
||||
'(defcomp ~c (&key) (div (config "x")))',
|
||||
)
|
||||
|
||||
def test_parity_mixed(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~layout (&key) (div (~nav) (~content) (~footer)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~content (&key) (main "pure content"))',
|
||||
'(defcomp ~footer (&key) (footer (config "name")))',
|
||||
)
|
||||
300
shared/sx/tests/test_router.py
Normal file
300
shared/sx/tests/test_router.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Tests for the router.sx spec — client-side route matching.
|
||||
|
||||
Tests the bootstrapped Python router functions (from sx_ref.py) and
|
||||
the SX page registry serialization (from helpers.py).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from shared.sx.ref import sx_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split-path-segments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSplitPathSegments:
|
||||
def test_simple(self):
|
||||
assert sx_ref.split_path_segments("/docs/hello") == ["docs", "hello"]
|
||||
|
||||
def test_root(self):
|
||||
assert sx_ref.split_path_segments("/") == []
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert sx_ref.split_path_segments("/docs/") == ["docs"]
|
||||
|
||||
def test_no_leading_slash(self):
|
||||
assert sx_ref.split_path_segments("docs/hello") == ["docs", "hello"]
|
||||
|
||||
def test_single_segment(self):
|
||||
assert sx_ref.split_path_segments("/about") == ["about"]
|
||||
|
||||
def test_deep_path(self):
|
||||
assert sx_ref.split_path_segments("/a/b/c/d") == ["a", "b", "c", "d"]
|
||||
|
||||
def test_empty(self):
|
||||
assert sx_ref.split_path_segments("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse-route-pattern
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseRoutePattern:
|
||||
def test_literal_only(self):
|
||||
result = sx_ref.parse_route_pattern("/docs/")
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "literal"
|
||||
assert result[0]["value"] == "docs"
|
||||
|
||||
def test_param(self):
|
||||
result = sx_ref.parse_route_pattern("/docs/<slug>")
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"type": "literal", "value": "docs"}
|
||||
assert result[1] == {"type": "param", "value": "slug"}
|
||||
|
||||
def test_multiple_params(self):
|
||||
result = sx_ref.parse_route_pattern("/users/<uid>/posts/<pid>")
|
||||
assert len(result) == 4
|
||||
assert result[0]["type"] == "literal"
|
||||
assert result[1] == {"type": "param", "value": "uid"}
|
||||
assert result[2]["type"] == "literal"
|
||||
assert result[3] == {"type": "param", "value": "pid"}
|
||||
|
||||
def test_root_pattern(self):
|
||||
result = sx_ref.parse_route_pattern("/")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatchRoute:
|
||||
def test_exact_match(self):
|
||||
params = sx_ref.match_route("/docs/", "/docs/")
|
||||
assert params is not None
|
||||
assert params == {}
|
||||
|
||||
def test_param_match(self):
|
||||
params = sx_ref.match_route("/docs/components", "/docs/<slug>")
|
||||
assert params is not None
|
||||
assert params == {"slug": "components"}
|
||||
|
||||
def test_no_match_different_length(self):
|
||||
result = sx_ref.match_route("/docs/a/b", "/docs/<slug>")
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_no_match_literal_mismatch(self):
|
||||
result = sx_ref.match_route("/api/hello", "/docs/<slug>")
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_root_match(self):
|
||||
params = sx_ref.match_route("/", "/")
|
||||
assert params is not None
|
||||
assert params == {}
|
||||
|
||||
def test_multiple_params(self):
|
||||
params = sx_ref.match_route("/users/42/posts/7", "/users/<uid>/posts/<pid>")
|
||||
assert params is not None
|
||||
assert params == {"uid": "42", "pid": "7"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find-matching-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindMatchingRoute:
|
||||
def _make_routes(self, patterns):
|
||||
"""Build route entries like boot.sx does — with parsed patterns."""
|
||||
routes = []
|
||||
for name, pattern in patterns:
|
||||
route = {
|
||||
"name": name,
|
||||
"path": pattern,
|
||||
"parsed": sx_ref.parse_route_pattern(pattern),
|
||||
"has-data": False,
|
||||
"content": "(div \"test\")",
|
||||
}
|
||||
routes.append(route)
|
||||
return routes
|
||||
|
||||
def test_first_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("docs-index", "/docs/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/docs/components", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "docs-page"
|
||||
assert match["params"] == {"slug": "components"}
|
||||
|
||||
def test_exact_before_param(self):
|
||||
routes = self._make_routes([
|
||||
("docs-index", "/docs/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/docs/", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "docs-index"
|
||||
|
||||
def test_no_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
result = sx_ref.find_matching_route("/unknown/path", routes)
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_root_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("about", "/about"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "home"
|
||||
|
||||
def test_params_not_on_original(self):
|
||||
"""find-matching-route should not mutate the original route entry."""
|
||||
routes = self._make_routes([("page", "/docs/<slug>")])
|
||||
match = sx_ref.find_matching_route("/docs/test", routes)
|
||||
assert match["params"] == {"slug": "test"}
|
||||
# Original should not have params key
|
||||
assert "params" not in routes[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page registry SX serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildPagesSx:
|
||||
"""Test the SX page registry format — serialize + parse round-trip."""
|
||||
|
||||
def test_round_trip_simple(self):
|
||||
"""SX dict literal round-trips through serialize → parse."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
# Build an SX dict literal like _build_pages_sx does
|
||||
entry = (
|
||||
"{:name " + _sx_literal("home")
|
||||
+ " :path " + _sx_literal("/")
|
||||
+ " :auth " + _sx_literal("public")
|
||||
+ " :has-data false"
|
||||
+ " :content " + _sx_literal("(~home-content)")
|
||||
+ " :closure {}}"
|
||||
)
|
||||
|
||||
parsed = parse_all(entry)
|
||||
assert len(parsed) == 1
|
||||
d = parsed[0]
|
||||
assert d["name"] == "home"
|
||||
assert d["path"] == "/"
|
||||
assert d["auth"] == "public"
|
||||
assert d["has-data"] is False
|
||||
assert d["content"] == "(~home-content)"
|
||||
assert d["closure"] == {}
|
||||
|
||||
def test_round_trip_multiple(self):
|
||||
"""Multiple SX dict literals parse as a list."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entries = []
|
||||
for name, path in [("home", "/"), ("docs", "/docs/<slug>")]:
|
||||
entry = (
|
||||
"{:name " + _sx_literal(name)
|
||||
+ " :path " + _sx_literal(path)
|
||||
+ " :has-data false"
|
||||
+ " :content " + _sx_literal("(div)")
|
||||
+ " :closure {}}"
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
text = "\n".join(entries)
|
||||
parsed = parse_all(text)
|
||||
assert len(parsed) == 2
|
||||
assert parsed[0]["name"] == "home"
|
||||
assert parsed[1]["name"] == "docs"
|
||||
assert parsed[1]["path"] == "/docs/<slug>"
|
||||
|
||||
def test_content_with_quotes(self):
|
||||
"""Content expressions with quotes survive serialization."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
content = '(~doc-page :title "Hello \\"World\\"")'
|
||||
entry = (
|
||||
"{:name " + _sx_literal("test")
|
||||
+ " :content " + _sx_literal(content)
|
||||
+ " :closure {}}"
|
||||
)
|
||||
parsed = parse_all(entry)
|
||||
assert parsed[0]["content"] == content
|
||||
|
||||
def test_closure_with_values(self):
|
||||
"""Closure dict with various value types."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entry = '{:name "test" :closure {:label "hello" :count 42 :active true}}'
|
||||
parsed = parse_all(entry)
|
||||
closure = parsed[0]["closure"]
|
||||
assert closure["label"] == "hello"
|
||||
assert closure["count"] == 42
|
||||
assert closure["active"] is True
|
||||
|
||||
def test_has_data_true(self):
|
||||
"""has-data true marks server-only pages."""
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entry = '{:name "analyzer" :path "/data" :has-data true :content "" :closure {}}'
|
||||
parsed = parse_all(entry)
|
||||
assert parsed[0]["has-data"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sx_literal helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxLiteral:
|
||||
def test_string(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("hello") == '"hello"'
|
||||
|
||||
def test_string_with_quotes(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal('say "hi"') == '"say \\"hi\\""'
|
||||
|
||||
def test_string_with_newline(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("line1\nline2") == '"line1\\nline2"'
|
||||
|
||||
def test_string_with_backslash(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("a\\b") == '"a\\\\b"'
|
||||
|
||||
def test_int(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(42) == "42"
|
||||
|
||||
def test_float(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(3.14) == "3.14"
|
||||
|
||||
def test_bool_true(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(True) == "true"
|
||||
|
||||
def test_bool_false(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(False) == "false"
|
||||
|
||||
def test_none(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(None) == "nil"
|
||||
|
||||
def test_empty_string(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("") == '""'
|
||||
Reference in New Issue
Block a user