"""IMP-56 (#90) u9 — Step 13 ``text_path_stamper`` wiring tests. Verifies that :func:`src.phase_z2_pipeline.render_slide` stamps each rendered ``text-line`` opening tag with ``data-text-path="{slot_key}.{line_index}"`` via the u8 stamper (``src.text_path_stamper.stamp_zone_html``). This is the wiring unit companion to the u8 module-level tests at ``tests/test_text_path_stamper.py``. Coverage axes (Stage 2 plan u9 + Stage 1 binding contract) : - happy path : real ``bim_current_problems_paired`` template emits ``text-line`` divs for list-valued slots; the stamper attaches ``data-text-path`` with matching ``{slot_key}.{line_index}``. - non-list slots skipped : ``title`` / ``row_*_left_label`` (scalars) do NOT receive ``data-text-path`` attributes. - empty list slots emit no stamps : rows whose body list is empty contribute zero stamps. - deterministic : repeated calls produce byte-identical HTML (no nondeterministic mutation of ``slot_payload`` between renders). - empty zone : the ``__empty__`` template_id short-circuit emits no ``data-text-path`` (the stamper short-circuits on empty stamps). Fully synthetic slot_payload — no real Phase Z run, no ``v4_full32_result.yaml`` dependency. Uses the real ``bim_current_problems_paired`` family template only to exercise the genuine Jinja2 + slide_base render path. """ from __future__ import annotations import re import pytest from src.phase_z2_pipeline import render_slide # ─── Fixture helpers ──────────────────────────────────────────────── def _layout_css() -> dict: """Minimal valid layout_css for a single-zone slide. Mirrors tests/phase_z2/test_slide_base_embedded_mode.py shape. """ return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"} def _paired_slot_payload( *, left_lines: list[str] | None = None, right_lines: list[str] | None = None, ) -> dict: """Build a slot_payload for the bim_current_problems_paired family. Only row_1 is populated by default; rows 2-4 stay empty so the template short-circuits to zero text-line divs for them. """ left_lines = left_lines if left_lines is not None else ["L1a", "L1b"] right_lines = right_lines if right_lines is not None else ["R1a"] payload: dict = { "title": "Synthetic Title", "row_1_left_label": "left pill 1", "row_1_left_body": [{"text": t, "indent": 0} for t in left_lines], "row_1_right_label": "right pill 1", "row_1_right_body": [{"text": t, "indent": 0} for t in right_lines], } for r in (2, 3, 4): payload[f"row_{r}_left_label"] = f"left pill {r}" payload[f"row_{r}_left_body"] = [] payload[f"row_{r}_right_label"] = f"right pill {r}" payload[f"row_{r}_right_body"] = [] return payload def _zone(template_id: str, slot_payload: dict) -> dict: return { "position": "primary", "template_id": template_id, "slot_payload": slot_payload, } def _render(zones: list[dict]) -> str: return render_slide( slide_title="t", slide_footer=None, zones_data=zones, layout_preset="single", layout_css=_layout_css(), gap_px=14, embedded_mode="embedded", ) # ─── Case 1 : happy path — list-valued slots stamped ───────────────── def test_render_slide_stamps_text_path_per_line(): """Each list-valued slot line gets data-text-path={slot}.{index}.""" payload = _paired_slot_payload( left_lines=["left line A", "left line B"], right_lines=["right line A"], ) html = _render([_zone("bim_current_problems_paired", payload)]) # left body 2 lines + right body 1 line = 3 stamps in row 1. assert 'data-text-path="row_1_left_body.0"' in html assert 'data-text-path="row_1_left_body.1"' in html assert 'data-text-path="row_1_right_body.0"' in html # row 2-4 are empty → no stamps for those slot_keys. assert "row_2_left_body" not in html assert "row_3_right_body" not in html def test_stamps_preserve_class_attribute(): """data-text-path injected before existing class attribute, both present.""" payload = _paired_slot_payload(left_lines=["only left"], right_lines=[]) html = _render([_zone("bim_current_problems_paired", payload)]) # The original class="text-line..." must survive verbatim alongside # the injected data-text-path attribute on the same opening tag. assert re.search( r'', html, ) is not None # ─── Case 2 : non-list slots are NOT stamped ───────────────────────── def test_non_list_slots_not_stamped(): """Scalar slot values (title, *_label) get no data-text-path.""" payload = _paired_slot_payload(left_lines=["x"], right_lines=["y"]) html = _render([_zone("bim_current_problems_paired", payload)]) # Scalar slots present in slot_payload as strings — must not receive # data-text-path stamps (u8 contract: scalar slots skipped silently # because they render outside text-line divs). assert 'data-text-path="title' not in html assert 'data-text-path="row_1_left_label' not in html assert 'data-text-path="row_1_right_label' not in html # ─── Case 3 : empty list slots contribute no stamps ────────────────── def test_empty_list_slots_no_stamps(): """Empty list slot yields zero stamps; template emits zero text-line divs.""" payload = _paired_slot_payload(left_lines=[], right_lines=[]) html = _render([_zone("bim_current_problems_paired", payload)]) # No row 1 lines at all (both bodies empty) → no row_1_*_body stamps. assert "data-text-path" not in html # ─── Case 4 : deterministic — repeated render produces same HTML ───── def test_render_with_stamp_is_deterministic(): """Same slot_payload → byte-identical HTML across two render_slide calls. Guards against the wiring layer accidentally mutating slot_payload between renders (the stamper itself only reads slot_payload; it operates on rendered_partial). Also guards against double-stamping. """ payload_1 = _paired_slot_payload() payload_2 = _paired_slot_payload() html_1 = _render([_zone("bim_current_problems_paired", payload_1)]) html_2 = _render([_zone("bim_current_problems_paired", payload_2)]) assert html_1 == html_2 # Counts must match — no double-stamp side effect on shared module state. assert html_1.count("data-text-path=") == html_2.count("data-text-path=") # 2 left + 1 right = 3 stamps for the default fixture. assert html_1.count("data-text-path=") == 3 # ─── Case 5 : __empty__ short-circuit emits no stamps ──────────────── def test_empty_template_short_circuit_no_stamps(): """``template_id=__empty__`` short-circuits before stamping; no stamps.""" html = _render([_zone("__empty__", {})]) assert "data-text-path" not in html # ─── Case 6 : slot_payload preserved (raw_content invariant) ───────── def test_render_does_not_mutate_slot_payload(): """Stamping must not mutate slot_payload list/dict contents. The stamper operates on rendered_partial HTML; the source slot_payload should be byte-identical before and after render_slide. Locks the raw_content preservation invariant at the wiring layer. """ payload = _paired_slot_payload( left_lines=["preserved A", "preserved B"], right_lines=["preserved C"], ) # Snapshot key list/dict identities and content. snapshot_left = list(payload["row_1_left_body"]) snapshot_left_text = [item["text"] for item in snapshot_left] snapshot_right_text = [item["text"] for item in payload["row_1_right_body"]] _ = _render([_zone("bim_current_problems_paired", payload)]) assert [item["text"] for item in payload["row_1_left_body"]] == snapshot_left_text assert [item["text"] for item in payload["row_1_right_body"]] == snapshot_right_text # Scalar slots untouched too. assert payload["title"] == "Synthetic Title"