Files
rose-ash/shared/sx/tests/test_page_data.py
giles b1f9e41027 Add unit tests for Phase 4 page data pipeline (20 tests)
Tests cover: SX wire format roundtrip for data dicts (11 tests),
kebab-case key conversion (4 tests), component dep computation for
:data pages (2 tests), and full pipeline simulation — serialize on
server, parse on client, merge into env, eval content (3 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:49:08 +00:00

286 lines
9.1 KiB
Python

"""Tests for Phase 4 page data pipeline.
Tests the serialize→parse roundtrip for data dicts (SX wire format),
the kebab-case key conversion, and component dep computation for
:data pages.
"""
import pytest
from shared.sx.parser import parse, parse_all, serialize
from shared.sx.types import Symbol, Keyword, NIL
# ---------------------------------------------------------------------------
# SX wire format roundtrip — data dicts
# ---------------------------------------------------------------------------
class TestDataSerializeRoundtrip:
"""Data dicts must survive serialize → parse as SX wire format."""
def test_simple_dict(self):
data = {"name": "hello", "count": 42}
sx = serialize(data)
parsed = parse_all(sx)
assert len(parsed) == 1
d = parsed[0]
assert d["name"] == "hello"
assert d["count"] == 42
def test_nested_list(self):
data = {"items": [1, 2, 3]}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["items"] == [1, 2, 3]
def test_nested_dict(self):
data = {"user": {"name": "alice", "active": True}}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["user"]["name"] == "alice"
assert d["user"]["active"] is True
def test_nil_value(self):
data = {"value": None}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["value"] is NIL or d["value"] is None
def test_boolean_values(self):
data = {"yes": True, "no": False}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["yes"] is True
assert d["no"] is False
def test_string_with_special_chars(self):
data = {"msg": 'He said "hello"\nNew line'}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["msg"] == 'He said "hello"\nNew line'
def test_empty_dict(self):
data = {}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d == {}
def test_list_of_dicts(self):
"""Data helpers often return lists of dicts (e.g. items)."""
data = {"items": [
{"label": "A", "value": 1},
{"label": "B", "value": 2},
]}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
items = d["items"]
assert len(items) == 2
assert items[0]["label"] == "A"
assert items[1]["value"] == 2
def test_float_values(self):
data = {"pi": 3.14, "neg": -0.5}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["pi"] == 3.14
assert d["neg"] == -0.5
def test_empty_string(self):
data = {"empty": ""}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["empty"] == ""
def test_empty_list(self):
data = {"items": []}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["items"] == []
# ---------------------------------------------------------------------------
# Kebab-case key conversion
# ---------------------------------------------------------------------------
class TestKebabCaseKeys:
"""evaluate_page_data converts underscore keys to kebab-case."""
def _kebab(self, d):
"""Same logic as evaluate_page_data."""
return {k.replace("_", "-"): v for k, v in d.items()}
def test_underscores_to_kebab(self):
d = {"total_count": 5, "is_active": True}
result = self._kebab(d)
assert "total-count" in result
assert "is-active" in result
assert result["total-count"] == 5
def test_no_underscores_unchanged(self):
d = {"name": "hello", "count": 3}
result = self._kebab(d)
assert result == d
def test_already_kebab_unchanged(self):
d = {"my-key": "val"}
result = self._kebab(d)
assert result == {"my-key": "val"}
def test_kebab_then_serialize_roundtrip(self):
"""Full pipeline: kebab-case → serialize → parse."""
data = {"total_count": 5, "page_title": "Test"}
kebab = self._kebab(data)
sx = serialize(kebab)
parsed = parse_all(sx)
d = parsed[0]
assert d["total-count"] == 5
assert d["page-title"] == "Test"
# ---------------------------------------------------------------------------
# Component deps for :data pages
# ---------------------------------------------------------------------------
class TestDataPageDeps:
"""_build_pages_sx should compute deps for :data pages too."""
def test_deps_computed_for_data_page(self):
from shared.sx.deps import components_needed
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
# Define a component
env = {}
for expr in pa('(defcomp ~card (&key title) (div title))'):
_trampoline(_eval(expr, env))
# Content that uses ~card — this is what a :data page's content looks like
content_src = '(~card :title page-title)'
deps = components_needed(content_src, env)
assert "~card" in deps
def test_deps_transitive_for_data_page(self):
from shared.sx.deps import components_needed
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
source = """
(defcomp ~inner (&key text) (span text))
(defcomp ~outer (&key title) (div (~inner :text title)))
"""
for expr in pa(source):
_trampoline(_eval(expr, env))
content_src = '(~outer :title page-title)'
deps = components_needed(content_src, env)
assert "~outer" in deps
assert "~inner" in deps
# ---------------------------------------------------------------------------
# Full data pipeline simulation
# ---------------------------------------------------------------------------
class TestDataPipelineSimulation:
"""Simulate the full data page pipeline without Quart context.
Server: data_helper() → dict → kebab-case → serialize → SX text
Client: SX text → parse → dict → merge into env → eval content
Note: uses str/list ops instead of HTML tags since the bare evaluator
doesn't have the HTML tag registry. The real client uses renderToDom.
"""
def test_full_pipeline(self):
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
# 1. Define a component that uses only pure primitives
env = {}
for expr in pa('(defcomp ~greeting (&key name time) (str "Hello " name " at " time))'):
_trampoline(_eval(expr, env))
# 2. Server: data helper returns a dict
data_result = {"user_name": "Alice", "server_time": "12:00"}
# 3. Server: kebab-case + serialize
kebab = {k.replace("_", "-"): v for k, v in data_result.items()}
sx_wire = serialize(kebab)
# 4. Client: parse SX wire format
parsed = pa(sx_wire)
assert len(parsed) == 1
data_dict = parsed[0]
# 5. Client: merge data into env
env.update(data_dict)
# 6. Client: eval content expression
content_src = '(~greeting :name user-name :time server-time)'
for expr in pa(content_src):
result = _trampoline(_eval(expr, env))
assert result == "Hello Alice at 12:00"
def test_pipeline_with_list_data(self):
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
for expr in pa('''
(defcomp ~item-list (&key items)
(map (fn (item) (get item "label")) items))
'''):
_trampoline(_eval(expr, env))
# Server data
data_result = {"items": [{"label": "One"}, {"label": "Two"}]}
sx_wire = serialize(data_result)
# Client parse + merge + eval
data_dict = pa(sx_wire)[0]
env.update(data_dict)
result = None
for expr in pa('(~item-list :items items)'):
result = _trampoline(_eval(expr, env))
assert result == ["One", "Two"]
def test_pipeline_data_isolation(self):
"""Different data for the same content produces different results."""
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
for expr in pa('(defcomp ~page (&key title count) (str title ": " count))'):
_trampoline(_eval(expr, env))
# Two different data payloads
for title, count, expected in [
("Posts", 42, "Posts: 42"),
("Users", 7, "Users: 7"),
]:
data = {"title": title, "count": count}
sx_wire = serialize(data)
data_dict = pa(sx_wire)[0]
page_env = dict(env)
page_env.update(data_dict)
for expr in pa('(~page :title title :count count)'):
result = _trampoline(_eval(expr, page_env))
assert result == expected