diff --git a/Front/client/src/pages/Home.tsx b/Front/client/src/pages/Home.tsx
index 2c158e5..0fab4b7 100644
--- a/Front/client/src/pages/Home.tsx
+++ b/Front/client/src/pages/Home.tsx
@@ -384,6 +384,14 @@ export default function Home() {
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
+ // IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
+ // entry-to-backend boundary. Surfaces the override payload + uploaded
+ // file name so the user can see exactly what crossed the wire when
+ // the pipeline fails silently. No env gate (silence is the bug).
+ console.log("[DIAG raw overrides]", {
+ file: state.uploadedFile.name,
+ overrides,
+ });
const result = await runPipeline(state.uploadedFile, overrides);
if (!result.success || !result.final_html_exists) {
diff --git a/Front/client/tests/handle_generate_diag.test.ts b/Front/client/tests/handle_generate_diag.test.ts
new file mode 100644
index 0000000..ba8296b
--- /dev/null
+++ b/Front/client/tests/handle_generate_diag.test.ts
@@ -0,0 +1,117 @@
+// IMP-42 u4 — Source-slice coverage for the unconditional handleGenerate
+// DIAG console.log on the frontend → backend boundary (issue #71).
+//
+// Scope (Stage 2 unit u4 contract):
+// 1) A single `console.log("[DIAG raw overrides]", ...)` call exists
+// inside handleGenerate and precedes the runPipeline call site.
+// 2) The DIAG call is unconditional — not wrapped in `if (...)` / `?:` /
+// env-var gate / `__DEV__`-style guard. "Silence is the bug" per
+// Stage 1 scope-lock (Codex #3) and the Step 13 backend mirror
+// already landed in u3.
+// 3) The DIAG payload carries shape-only metadata — uploaded file name
+// and the override payload object — without referencing raw MDX
+// content or any other sample-specific identifier (RULE 0).
+//
+// Why source-slice (per Stage 2 plan): Home.tsx handleGenerate is wired to
+// React state, toast, and a 700-line component tree; the cheapest way to
+// pin a single-line surface and prove placement relative to runPipeline is
+// to read the source and assert ordering. No React rendering, no fetch
+// mock, no DOM. Mirrors the existing pure-helper pattern in
+// tests/imp41_application_mode.test.ts.
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const HOME_TSX_PATH = resolve(__dirname, "..", "src", "pages", "Home.tsx");
+const HOME_TSX_SOURCE = readFileSync(HOME_TSX_PATH, "utf-8");
+
+// Locate the handleGenerate callback body. The closing brace of
+// useCallback's `async () => { ... }` is the next line whose indent matches
+// the opening `useCallback(async () => {` exactly — but a simpler proxy is
+// "from the handleGenerate keyword to the next useCallback declaration or
+// the end-of-file." This is sufficient to scope every assertion below to
+// the right function body.
+function sliceHandleGenerateBody(source: string): string {
+ const startMarker = "const handleGenerate = useCallback(async () =>";
+ const startIdx = source.indexOf(startMarker);
+ if (startIdx === -1) {
+ throw new Error("handleGenerate declaration not found in Home.tsx");
+ }
+ // End at the next top-level `const ` that begins a new useCallback /
+ // useMemo / hook binding. handleGenerate is followed by additional
+ // hooks (handleFileUpload sibling pattern); slicing to the next
+ // declaration is more than enough to capture the full body.
+ const afterStart = source.slice(startIdx + startMarker.length);
+ const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
+ return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
+}
+
+const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX_SOURCE);
+
+describe("handleGenerate [DIAG raw overrides] (IMP-42 u4)", () => {
+ it("emits exactly one console.log labelled '[DIAG raw overrides]' inside handleGenerate", () => {
+ const matches = HANDLE_GENERATE_BODY.match(
+ /console\.log\(\s*"\[DIAG raw overrides\]"/g,
+ );
+ expect(matches).not.toBeNull();
+ // Exactly one DIAG site per Stage 2 contract — multiple calls would
+ // either be a copy-paste regression or evidence that the helper
+ // moved without removing the old site.
+ expect(matches?.length).toBe(1);
+ });
+
+ it("places the DIAG console.log before the runPipeline call site", () => {
+ const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
+ const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
+ "runPipeline(state.uploadedFile, overrides)",
+ );
+ expect(diagIdx).toBeGreaterThan(-1);
+ expect(runPipelineIdx).toBeGreaterThan(-1);
+ expect(diagIdx).toBeLessThan(runPipelineIdx);
+ });
+
+ it("is unconditional — no env-var gate or if-guard wraps the DIAG call", () => {
+ // Slice the 80 chars immediately preceding the DIAG console.log and
+ // confirm none of the common gating patterns appear directly above.
+ const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
+ const preface = HANDLE_GENERATE_BODY.slice(Math.max(0, diagIdx - 200), diagIdx);
+ // Stage 1 contract: silence is the bug. Any gate here is a regression.
+ expect(preface).not.toMatch(/if\s*\([^)]*\)\s*$/m);
+ expect(preface).not.toMatch(/process\.env/);
+ expect(preface).not.toMatch(/import\.meta\.env/);
+ expect(preface).not.toMatch(/__DEV__/);
+ expect(preface).not.toMatch(/DIAG_VERBOSE/i);
+ expect(preface).not.toMatch(/DEBUG/);
+ });
+
+ it("forwards the file name and overrides object as shape-only payload", () => {
+ // The DIAG payload must include the uploaded file name (so the user
+ // can correlate the log line with the MDX they uploaded) and the
+ // overrides object (so the user can see what crossed the wire).
+ // It must NOT spread MDX text content or any other large blob —
+ // sample-agnostic and reviewable in a single log line.
+ const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
+ const window = HANDLE_GENERATE_BODY.slice(diagIdx, diagIdx + 300);
+ // Both fields appear in the payload object literal.
+ expect(window).toMatch(/file:\s*state\.uploadedFile\.name/);
+ expect(window).toMatch(/\boverrides\b/);
+ // Sanity: the payload does not pass MDX raw content / a File blob.
+ expect(window).not.toMatch(/mdxContent|rawMdx|normalizedContent/);
+ });
+
+ it("runs after flushUserOverrides() so the persisted PUT is already committed", () => {
+ // Ordering invariant from IMP-52 u10 (already in place):
+ // flushUserOverrides() → DIAG → runPipeline
+ // Asserts the DIAG sits between the flush and the network call so the
+ // logged overrides match what backend reads from disk.
+ const flushIdx = HANDLE_GENERATE_BODY.indexOf("await flushUserOverrides()");
+ const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
+ const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
+ "runPipeline(state.uploadedFile, overrides)",
+ );
+ expect(flushIdx).toBeGreaterThan(-1);
+ expect(diagIdx).toBeGreaterThan(flushIdx);
+ expect(diagIdx).toBeLessThan(runPipelineIdx);
+ });
+});
diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py
index 73e77f0..fd798b1 100644
--- a/src/phase_z2_pipeline.py
+++ b/src/phase_z2_pipeline.py
@@ -2632,6 +2632,84 @@ def _remeasure_after_frame_reselect(
}
+# ─── IMP-42 u3 (#71) — unconditional Step 12 / Step 13 DIAG log helper ──
+
+
+def _emit_diag_zones_shape(stage_label: str, zones_data: list[dict], **extra_fields) -> None:
+ """IMP-42 u3 (#71) — emit shape-only zone metadata to stdout.
+
+ Used at the Step 12 slot_payload emit site and the Step 13 render_slide
+ entry site to make the silent 3-hop handoff visible in the terminal.
+ Shape-only — never logs raw slot_payload values — so the diag is
+ sample-agnostic (RULE 0) and never leaks user content.
+
+ No env gate: silence is the bug this IMP fights, so the log fires
+ unconditionally on every slide loop.
+ """
+ payload = {
+ "zones_count": len(zones_data),
+ "zones": [
+ {
+ "i": i,
+ "position": z.get("position"),
+ "template_id": z.get("template_id"),
+ "slot_keys": (
+ sorted(z["slot_payload"].keys())
+ if isinstance(z.get("slot_payload"), dict) else None
+ ),
+ }
+ for i, z in enumerate(zones_data)
+ ],
+ }
+ payload.update(extra_fields)
+ print(
+ f"[DIAG] phase_z2 {stage_label} "
+ + json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ flush=True,
+ )
+
+
+# ─── IMP-42 u2 (#71) — post-render HTML invalid path char detector ──
+
+_INVALID_PATH_ATTR_RE = re.compile(
+ r"""(src|href)\s*=\s*["']([^"']*)["']|url\(\s*['"]?([^)'"\s]+)['"]?\s*\)""",
+ re.IGNORECASE,
+)
+_INVALID_PATH_CHARS = ("\\", "&", "'")
+
+
+def _scan_rendered_html_for_invalid_path_chars(html: str, context: str) -> None:
+ """IMP-42 u2 (#71) — fail loud when rendered HTML asset references
+ contain invalid path characters in src / href / url(...) values.
+
+ Catches three silent fail vectors at the rendered HTML boundary that
+ surface downstream as 404 / asset-load failures far from upstream cause:
+ - Windows backslash from str(Path) (e.g. ``assets\\img.png``).
+ - Autoescape entity ``&`` (raw ``&`` in raw path source string).
+ - Autoescape entity ``'`` (raw ``'`` in raw path source string).
+
+ Raises ValueError on first hit, citing context, attr type, value snippet.
+ Scope-locked to rendered HTML asset attrs only; never inspects CSS grid
+ metadata or static template strings.
+ """
+ for match in _INVALID_PATH_ATTR_RE.finditer(html):
+ if match.group(3) is not None:
+ attr_label = "url(...)"
+ value = match.group(3)
+ else:
+ attr_label = match.group(1).lower()
+ value = match.group(2)
+ for bad in _INVALID_PATH_CHARS:
+ if bad in value:
+ snippet = value if len(value) <= 120 else value[:117] + "..."
+ raise ValueError(
+ f"render_slide: {context} — invalid path char {bad!r} in "
+ f"{attr_label} value (value={snippet}). "
+ "Likely upstream: Windows backslash from str(Path) or "
+ "autoescape of '&' / \"'\" in raw path string."
+ )
+
+
def render_slide(slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str,
layout_css: dict, gap_px: int = GRID_GAP,
@@ -2651,22 +2729,60 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
"expected one of 'auto', 'embedded', 'standalone'"
)
+ # IMP-42 u3 (#71) — unconditional Step 13 entry DIAG log.
+ _emit_diag_zones_shape(
+ "Step 13 render_slide entry",
+ zones_data,
+ layout_preset=layout_preset,
+ embedded_mode=embedded_mode,
+ )
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
- for zone in zones_data:
+ for zone_index, zone in enumerate(zones_data):
# Stage 4 Part 2 (Codex #10 Catch N) — empty zone produced by section
# assignment override has no partial template; render an empty string so
# the slide_base zones loop preserves grid identity without TemplateNotFound.
if zone.get("template_id") == "__empty__":
zone["partial_html"] = ""
continue
- partial = env.get_template(f"families/{zone['template_id']}.html")
- zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"])
+ # IMP-42 u1 (#71) — fail-loud precondition for Step 13 partial render.
+ # Catches the silent fail vector where Step 12 emits a zone dict missing
+ # `template_id` / `slot_payload`. Error message cites zone_index +
+ # missing key so the diag is actionable (vs Jinja TemplateNotFound /
+ # KeyError surfacing far from the upstream emit site).
+ template_id = zone.get("template_id")
+ if not isinstance(template_id, str) or not template_id:
+ raise TypeError(
+ f"render_slide: zones_data[{zone_index}] precondition failed — "
+ f"`template_id` must be a non-empty str, got {type(template_id).__name__}={template_id!r}"
+ )
+ if "slot_payload" not in zone:
+ raise TypeError(
+ f"render_slide: zones_data[{zone_index}] precondition failed — "
+ f"`slot_payload` key missing (template_id={template_id!r})"
+ )
+ slot_payload = zone["slot_payload"]
+ if not isinstance(slot_payload, dict):
+ raise TypeError(
+ f"render_slide: zones_data[{zone_index}] precondition failed — "
+ f"`slot_payload` must be a dict, got {type(slot_payload).__name__} "
+ f"(template_id={template_id!r})"
+ )
+ partial = env.get_template(f"families/{template_id}.html")
+ rendered_partial = partial.render(slot_payload=slot_payload)
+ # IMP-42 u2 (#71) — fail loud on invalid path chars in rendered HTML
+ # asset refs (src / href / url(...)). Catches Windows backslash and
+ # autoescape entity vectors before they reach the browser as 404.
+ _scan_rendered_html_for_invalid_path_chars(
+ rendered_partial,
+ f"zones_data[{zone_index}] template_id={template_id!r}",
+ )
+ zone["partial_html"] = rendered_partial
base = env.get_template("slide_base.html")
- return base.render(
+ rendered_base = base.render(
slide_title=slide_title,
slide_footer=slide_footer,
zones=zones_data,
@@ -2676,6 +2792,11 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
token_css=_read_token_css(),
embedded_mode=embedded_mode,
)
+ # IMP-42 u2 (#71) — also scan the assembled slide_base output to cover
+ # asset refs introduced by the slide-base shell itself (title / footer /
+ # popup slots) outside the per-zone partial scope.
+ _scan_rendered_html_for_invalid_path_chars(rendered_base, "slide_base")
+ return rendered_base
# ─── Selenium check (single slide + per-zone) ──────────────────
@@ -5384,6 +5505,8 @@ def run_phase_z2_mvp1(
outputs=["step12_slot_payload.json"],
note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).",
)
+ # IMP-42 u3 (#71) — unconditional Step 12 slot_payload emit DIAG log.
+ _emit_diag_zones_shape("Step 12 slot_payload emit", zones_data)
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
diff --git a/tests/phase_z2/test_phase_z2_diag_invalid_char.py b/tests/phase_z2/test_phase_z2_diag_invalid_char.py
new file mode 100644
index 0000000..8655810
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_diag_invalid_char.py
@@ -0,0 +1,135 @@
+"""IMP-42 u2 (#71) — post-render HTML invalid path char detector diag tests.
+
+Stage 1/2 scope-lock §B: rendered partial / base HTML output must fail loud
+with a typed error when src / href / url(...) attribute values contain
+invalid path characters that would silently surface downstream as 404 /
+asset-load failures.
+
+Three production vectors are covered:
+ - Windows backslash from ``str(Path)`` (e.g. ``assets\\img.png``).
+ - Autoescape entity ``&`` (raw ``&`` in raw path string).
+ - Autoescape entity ``'`` (raw ``'`` in raw path string).
+
+Assertions cover RULE 0 generality:
+ - error type is ValueError (typed, not bare exception)
+ - error message cites context label + attr type + value snippet
+ - clean rendered HTML (forward slashes only) does not raise
+ - non-attribute backslash (body text) does not raise
+"""
+from __future__ import annotations
+
+import pytest
+
+from src.phase_z2_pipeline import _scan_rendered_html_for_invalid_path_chars
+
+
+# ─── backslash vector ────────────────────────────────────────────
+
+def test_backslash_in_src_raises_with_context_and_attr_label():
+ html = '
'
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(html, "zone[0] template_id='foo'")
+ msg = str(exc_info.value)
+ assert "zone[0] template_id='foo'" in msg
+ assert "src" in msg
+ assert "assets\\img.png" in msg
+
+
+def test_backslash_in_href_raises():
+ html = ''
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+ msg = str(exc_info.value)
+ assert "href" in msg
+ assert "styles\\app.css" in msg
+
+
+def test_backslash_in_url_raises():
+ html = ""
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+ msg = str(exc_info.value)
+ assert "url(...)" in msg
+ assert "images\\bg.png" in msg
+
+
+def test_backslash_in_url_with_quotes_raises():
+ html = ""
+ with pytest.raises(ValueError):
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+
+
+# ─── autoescape entity vectors ───────────────────────────────────
+
+def test_escaped_ampersand_in_src_raises():
+ html = '
'
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+ msg = str(exc_info.value)
+ assert "&" in msg
+ assert "src" in msg
+
+
+def test_escaped_apostrophe_in_href_raises():
+ html = 'x'
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+ msg = str(exc_info.value)
+ assert "'" in msg
+ assert "href" in msg
+
+
+def test_escaped_ampersand_in_url_raises():
+ html = ""
+ with pytest.raises(ValueError):
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+
+
+# ─── negative cases (must NOT raise) ─────────────────────────────
+
+def test_clean_forward_slash_src_does_not_raise():
+ html = '
'
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+
+
+def test_clean_url_does_not_raise():
+ html = ""
+ _scan_rendered_html_for_invalid_path_chars(html, "ctx")
+
+
+def test_backslash_in_body_text_does_not_raise():
+ # Backslash outside src/href/url is not a path-attr signal.
+ html = "
Windows path example: C:\\Users\\foo
" + _scan_rendered_html_for_invalid_path_chars(html, "ctx") + + +def test_escaped_entities_in_body_text_do_not_raise(): + # Body-text autoescape (e.g. legitimate & in copy) is not a path signal. + html = "AT&T 'quoted' text
" + _scan_rendered_html_for_invalid_path_chars(html, "ctx") + + +def test_empty_html_does_not_raise(): + _scan_rendered_html_for_invalid_path_chars("", "ctx") + + +# ─── error message contract ────────────────────────────────────── + +def test_error_message_truncates_long_value_to_snippet(): + long_path = "a" * 200 + "\\img.png" + html = f'
'
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(
+ html, "zones_data[7] template_id='dx_sw_necessity_three_perspectives'"
+ )
+ msg = str(exc_info.value)
+ assert "zones_data[7]" in msg
+ assert "dx_sw_necessity_three_perspectives" in msg
diff --git a/tests/phase_z2/test_phase_z2_diag_render_assertions.py b/tests/phase_z2/test_phase_z2_diag_render_assertions.py
new file mode 100644
index 0000000..54f9122
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_diag_render_assertions.py
@@ -0,0 +1,116 @@
+"""IMP-42 u1 (#71) — render_slide precondition assertion diag tests.
+
+Stage 1/2 scope-lock §A: Step 13 `render_slide()` partial render loop must
+fail loud with a typed error when a zone dict is missing `template_id` or
+`slot_payload`, instead of silently surfacing as Jinja `TemplateNotFound`
+or `KeyError` far from the Step 12 emit site.
+
+Assertions cover RULE 0 generality:
+ - error type is TypeError (typed, not bare AssertionError / KeyError)
+ - error message cites zone index + missing key
+ - empty-zone short-circuit (`__empty__`) still bypasses the precondition,
+ preserving the existing grid-identity behaviour from Codex #10 Catch N.
+"""
+from __future__ import annotations
+
+import pytest
+
+from src.phase_z2_pipeline import render_slide
+
+
+def _layout_css() -> dict:
+ return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
+
+
+def _ok_zone() -> dict:
+ return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
+
+
+def _render(zones_data: list[dict]) -> str:
+ return render_slide(
+ slide_title="t",
+ slide_footer=None,
+ zones_data=zones_data,
+ layout_preset="single",
+ layout_css=_layout_css(),
+ gap_px=14,
+ embedded_mode="embedded",
+ )
+
+
+def test_template_id_missing_raises_typed_error_with_index_and_key():
+ zone = {"position": "primary", "slot_payload": {}}
+ with pytest.raises(TypeError) as exc_info:
+ _render([zone])
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "template_id" in msg
+
+
+def test_template_id_empty_string_raises_typed_error():
+ zone = {"position": "primary", "template_id": "", "slot_payload": {}}
+ with pytest.raises(TypeError) as exc_info:
+ _render([zone])
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "template_id" in msg
+ assert "non-empty" in msg
+
+
+def test_template_id_none_raises_typed_error():
+ zone = {"position": "primary", "template_id": None, "slot_payload": {}}
+ with pytest.raises(TypeError) as exc_info:
+ _render([zone])
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "template_id" in msg
+
+
+def test_template_id_non_string_raises_typed_error():
+ zone = {"position": "primary", "template_id": 42, "slot_payload": {}}
+ with pytest.raises(TypeError) as exc_info:
+ _render([zone])
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "template_id" in msg
+
+
+def test_slot_payload_missing_raises_typed_error_with_index_and_key():
+ zone = {"position": "primary", "template_id": "__placeholder__"}
+ with pytest.raises(TypeError) as exc_info:
+ _render([zone])
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "slot_payload" in msg
+
+
+def test_slot_payload_non_dict_raises_typed_error():
+ zone = {
+ "position": "primary",
+ "template_id": "__placeholder__",
+ "slot_payload": ["not", "a", "dict"],
+ }
+ with pytest.raises(TypeError) as exc_info:
+ _render([zone])
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "slot_payload" in msg
+ assert "dict" in msg
+
+
+def test_second_zone_failure_reports_correct_index():
+ zones = [_ok_zone(), {"position": "secondary", "slot_payload": {}}]
+ with pytest.raises(TypeError) as exc_info:
+ _render(zones)
+ msg = str(exc_info.value)
+ assert "zones_data[1]" in msg
+ assert "template_id" in msg
+
+
+def test_empty_zone_short_circuit_bypasses_precondition():
+ # __empty__ short-circuit must run before precondition checks so that
+ # legitimate empty zones (no slot_payload required) still render.
+ zones = [{"position": "primary", "template_id": "__empty__"}]
+ html = _render(zones)
+ assert isinstance(html, str)
+ assert len(html) > 0
diff --git a/tests/phase_z2/test_phase_z2_diag_smoke_general.py b/tests/phase_z2/test_phase_z2_diag_smoke_general.py
new file mode 100644
index 0000000..f3a0aff
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_diag_smoke_general.py
@@ -0,0 +1,92 @@
+"""IMP-42 u5 (#71) — general 32-frame smoke for diag tools (registry-driven).
+
+Stage 1/2 RULE 0 lock: u1 (precondition assert), u2 (invalid-path detector),
+and u3 (backend DIAG) must work GENERALLY across every frame declared in
+``templates/phase_z2/catalog/frame_contracts.yaml`` — not only the MDX
+03/04/05 samples that motivated #71.
+
+The smoke enumerates every top-level frame contract and parametrizes the
+three diag behaviors against each ``template_id``. AI = 0, no visual_check,
+no real partial render — payloads are synthetic so the coverage stays
+sample-agnostic and never depends on frame-specific slot shapes.
+
+Each parametrized case covers one silent-fail vector from #71 root cause:
+ - u1 precondition: Step 13 partial render must fail loud on missing
+ ``slot_payload`` regardless of which frame contract is in play.
+ - u2 invalid path: post-render asset-ref scan must fire on a synthetic
+ backslash ``src`` value when the context cites any frame's id.
+ - u3 DIAG: ``_emit_diag_zones_shape`` must include the frame's
+ ``template_id`` in the JSON payload for every contract.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from src.phase_z2_mapper import load_frame_contracts
+from src.phase_z2_pipeline import (
+ _emit_diag_zones_shape,
+ _scan_rendered_html_for_invalid_path_chars,
+ render_slide,
+)
+
+
+_FRAME_IDS = sorted(load_frame_contracts().keys())
+
+
+def test_registry_has_expected_frame_count():
+ # Pin the 32-frame floor — additions are auto-covered by parametrize,
+ # while a regression that drops below 32 surfaces here loud.
+ assert len(_FRAME_IDS) >= 32, (
+ f"frame_contracts.yaml expected ≥ 32 entries, got {len(_FRAME_IDS)}: "
+ f"{_FRAME_IDS}"
+ )
+
+
+def _layout_css() -> dict:
+ return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
+
+
+@pytest.mark.parametrize("template_id", _FRAME_IDS)
+def test_u1_precondition_fires_for_every_frame(template_id):
+ zone = {"position": "primary", "template_id": template_id}
+ with pytest.raises(TypeError) as exc_info:
+ render_slide(
+ slide_title="t",
+ slide_footer=None,
+ zones_data=[zone],
+ layout_preset="single",
+ layout_css=_layout_css(),
+ gap_px=14,
+ embedded_mode="embedded",
+ )
+ msg = str(exc_info.value)
+ assert "zones_data[0]" in msg
+ assert "slot_payload" in msg
+
+
+@pytest.mark.parametrize("template_id", _FRAME_IDS)
+def test_u2_invalid_char_detector_fires_for_every_frame_context(template_id):
+ html = '
'
+ ctx = f"zones_data[0] template_id={template_id!r}"
+ with pytest.raises(ValueError) as exc_info:
+ _scan_rendered_html_for_invalid_path_chars(html, ctx)
+ msg = str(exc_info.value)
+ assert template_id in msg
+ assert "src" in msg
+
+
+@pytest.mark.parametrize("template_id", _FRAME_IDS)
+def test_u3_diag_emits_template_id_for_every_frame(template_id, capsys):
+ zones = [
+ {"position": "primary", "template_id": template_id, "slot_payload": {}}
+ ]
+ _emit_diag_zones_shape("Step 12 slot_payload emit", zones)
+ line = capsys.readouterr().out.strip()
+ prefix = "[DIAG] phase_z2 Step 12 slot_payload emit "
+ assert line.startswith(prefix), f"missing DIAG prefix on line={line!r}"
+ payload = json.loads(line[len(prefix):])
+ assert payload["zones_count"] == 1
+ assert payload["zones"][0]["template_id"] == template_id
+ assert payload["zones"][0]["slot_keys"] == []
diff --git a/tests/phase_z2/test_phase_z2_diag_terminal_logs.py b/tests/phase_z2/test_phase_z2_diag_terminal_logs.py
new file mode 100644
index 0000000..edcda78
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_diag_terminal_logs.py
@@ -0,0 +1,223 @@
+"""IMP-42 u3 (#71) — unconditional Step 12 / Step 13 backend DIAG terminal logs.
+
+Stage 1/2 scope-lock §C-backend: Step 12 slot_payload emit + Step 13
+render_slide entry must each emit a shape-only `[DIAG]` line to stdout
+on every slide loop, with no env gate. The line carries enough zone
+shape (position / template_id / slot_payload key list) to debug the
+silent 3-hop handoff documented in #71, without leaking raw slot
+content (RULE 0 sample-agnostic).
+
+Coverage:
+ - helper emits `[DIAG] phase_z2