"""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