feat(#71): IMP-42 u1~u5 silent fail chain diagnostics (assert + invalid-char detector + DIAG log)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 24s

Stage 4 binding scope — diagnostic-only, fail-loud, sample-agnostic
(RULE 0 / AI-isolation contract). No production behavior change beyond
fail-loud raises on previously-silent failure classes.

u1 src/phase_z2_pipeline.py:2747-2772 — render_slide precondition assert
   (template_id non-empty str + slot_payload dict), placed after the
   `__empty__` short-circuit at 2740 to preserve empty-zone grid behavior.
u2 src/phase_z2_pipeline.py:2681-2710 — _scan_rendered_html_for_invalid_path_chars
   helper covering src / href / url(...) values for backslash, &, '.
   Invoked on partial render (2778) and slide_base assembly (2798).
u3 src/phase_z2_pipeline.py:2638-2676,2733,5509 — _emit_diag_zones_shape
   shape-only [DIAG] JSON at Step 12 slot_payload emit and Step 13
   render_slide entry. No env gate — silence is the bug.
u4 Front/client/src/pages/Home.tsx:388-392 — unconditional [DIAG raw overrides]
   console.log on handleGenerate boundary, after flushUserOverrides() and
   immediately before runPipeline.
u5 tests/phase_z2/test_phase_z2_diag_smoke_general.py — 32-frame general
   smoke driven by load_frame_contracts() registry (not literal MDX 03/04/05),
   parametrizes u1/u2/u3 across the full frame_contracts.yaml top-level.

Tests (Stage 4 verification PASS):
- u1 8 passed, u2 14 passed, u3 12 passed, u4 5 passed, u5 97 passed.
- Backend full regression tests/phase_z2/ 499 passed in 110.84s.
- Frontend full regression 182 passed in 1.10s.

Out of scope (separate axes):
- Path normalization / as_posix migration.
- Autoescape policy change.
- build_layout_css refactor (Stage 1 category-error rejection).
- Recovery / auto-fix on detected invalid path.
- MDX content / frame-selection / zone-composition change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 08:28:54 +09:00
parent c59864eb9a
commit 5deeb97cf6
7 changed files with 818 additions and 4 deletions

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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 = ("\\", "&amp;", "&#39;")
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 ``&amp;`` (raw ``&`` in raw path source string).
- Autoescape entity ``&#39;`` (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 강제.

View File

@@ -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 ``&amp;`` (raw ``&`` in raw path string).
- Autoescape entity ``&#39;`` (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 = '<img src="assets\\img.png">'
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 = '<link href="styles\\app.css" rel="stylesheet">'
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 = "<style>body { background: url(images\\bg.png); }</style>"
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 = "<style>div { background: url('images\\bg.png'); }</style>"
with pytest.raises(ValueError):
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
# ─── autoescape entity vectors ───────────────────────────────────
def test_escaped_ampersand_in_src_raises():
html = '<img src="assets/img&amp;v=1.png">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "&amp;" in msg
assert "src" in msg
def test_escaped_apostrophe_in_href_raises():
html = '<a href="docs/it&#39;s-here.pdf">x</a>'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "&#39;" in msg
assert "href" in msg
def test_escaped_ampersand_in_url_raises():
html = "<style>div { background: url('img&amp;.png'); }</style>"
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 = '<img src="assets/img.png"><link href="styles/app.css" rel="stylesheet">'
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
def test_clean_url_does_not_raise():
html = "<style>div { background: url('images/bg.png'); }</style>"
_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 = "<p>Windows path example: C:\\Users\\foo</p>"
_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 &amp; in copy) is not a path signal.
html = "<p>AT&amp;T &#39;quoted&#39; text</p>"
_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'<img src="{long_path}">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "..." in msg # truncation marker present
def test_error_message_cites_context_label_verbatim():
html = '<img src="x\\y.png">'
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

View File

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

View File

@@ -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 = '<img src="assets\\img.png">'
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"] == []

View File

@@ -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 <stage_label>` prefix
- helper payload is structured JSON with zones_count + per-zone shape
- per-zone shape includes i / position / template_id / slot_keys
- slot_keys is a sorted key list (never raw values)
- slot_keys is null when slot_payload is missing or non-dict
- extra_fields are merged into the payload at top level
- render_slide() entry call site fires the helper on every invocation
- source-slice confirms Step 12 emit site invokes the helper after
the slot_payload `_write_step_artifact(...)` call.
Diag is unconditional — no env-var gate; silence is the bug per Stage 1.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src import phase_z2_pipeline
from src.phase_z2_pipeline import (
_emit_diag_zones_shape,
render_slide,
)
# ─── helper unit tests ───────────────────────────────────────────
def _parse_diag_line(line: str, expected_label: str) -> dict:
prefix = f"[DIAG] phase_z2 {expected_label} "
assert line.startswith(prefix), (
f"expected DIAG prefix {prefix!r}, got line={line!r}"
)
return json.loads(line[len(prefix):])
def test_helper_emits_diag_prefix_and_json(capsys):
zones = [{"position": "primary", "template_id": "foo", "slot_payload": {"a": 1, "b": 2}}]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
captured = capsys.readouterr().out.strip().splitlines()
assert len(captured) == 1
payload = _parse_diag_line(captured[0], "Step 12 slot_payload emit")
assert payload["zones_count"] == 1
assert payload["zones"][0]["position"] == "primary"
assert payload["zones"][0]["template_id"] == "foo"
def test_helper_slot_keys_is_sorted_key_list_not_values(capsys):
# Raw values ("secret content") must not leak into the diag line.
zones = [{
"position": "primary",
"template_id": "foo",
"slot_payload": {"z_last": "secret content", "a_first": "another secret"},
}]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
line = capsys.readouterr().out.strip()
assert "secret content" not in line
assert "another secret" not in line
payload = _parse_diag_line(line, "Step 12 slot_payload emit")
assert payload["zones"][0]["slot_keys"] == ["a_first", "z_last"]
def test_helper_slot_keys_null_when_slot_payload_missing(capsys):
zones = [{"position": "primary", "template_id": "__empty__"}]
_emit_diag_zones_shape("Step 13 render_slide entry", zones)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
assert payload["zones"][0]["slot_keys"] is None
def test_helper_slot_keys_null_when_slot_payload_non_dict(capsys):
zones = [{"position": "primary", "template_id": "foo", "slot_payload": ["not", "dict"]}]
_emit_diag_zones_shape("Step 13 render_slide entry", zones)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
assert payload["zones"][0]["slot_keys"] is None
def test_helper_per_zone_index_threading(capsys):
zones = [
{"position": "top", "template_id": "alpha", "slot_payload": {}},
{"position": "bottom_l", "template_id": "beta", "slot_payload": {"k": "v"}},
{"position": "bottom_r", "template_id": "gamma", "slot_payload": {"k": "v"}},
]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 12 slot_payload emit")
assert payload["zones_count"] == 3
assert [z["i"] for z in payload["zones"]] == [0, 1, 2]
assert [z["position"] for z in payload["zones"]] == ["top", "bottom_l", "bottom_r"]
assert [z["template_id"] for z in payload["zones"]] == ["alpha", "beta", "gamma"]
def test_helper_extra_fields_merged_into_payload(capsys):
zones = [{"position": "primary", "template_id": "__empty__"}]
_emit_diag_zones_shape(
"Step 13 render_slide entry",
zones,
layout_preset="single",
embedded_mode="embedded",
)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
assert payload["layout_preset"] == "single"
assert payload["embedded_mode"] == "embedded"
def test_helper_empty_zones_list_still_emits_line(capsys):
# No zones is a valid (degenerate) shape — diag must still fire, never silent.
_emit_diag_zones_shape("Step 12 slot_payload emit", [])
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 12 slot_payload emit")
assert payload["zones_count"] == 0
assert payload["zones"] == []
# ─── render_slide entry call site integration ────────────────────
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def test_render_slide_entry_emits_step13_diag_on_every_call(capsys):
zones = [{"position": "primary", "template_id": "__empty__"}]
render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode="embedded",
)
out = capsys.readouterr().out
step13_lines = [
ln for ln in out.splitlines()
if ln.startswith("[DIAG] phase_z2 Step 13 render_slide entry ")
]
assert len(step13_lines) == 1, (
f"expected exactly 1 Step 13 DIAG line, got {len(step13_lines)} from out={out!r}"
)
payload = _parse_diag_line(step13_lines[0], "Step 13 render_slide entry")
assert payload["layout_preset"] == "single"
assert payload["embedded_mode"] == "embedded"
assert payload["zones_count"] == 1
def test_render_slide_fires_step13_diag_before_template_lookup(capsys):
# Diag must fire even when the precondition (u1) later raises — the diag
# is at entry, so the user sees the zone shape even on failure.
zones = [{"position": "primary", "slot_payload": {}}]
with pytest.raises(TypeError):
render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode="embedded",
)
out = capsys.readouterr().out
assert "[DIAG] phase_z2 Step 13 render_slide entry " in out
# ─── Step 12 emit call site source-slice ──────────────────────────
def test_step12_emit_call_site_invokes_helper_after_artifact_write():
# The Step 12 emit site is buried inside the orchestrator; rather than
# spin up the full pipeline, assert by source-slice that the helper is
# invoked with the "Step 12 slot_payload emit" label *after* the
# _write_step_artifact(... step12 ... "slot_payload" ...) call.
src = Path(phase_z2_pipeline.__file__).read_text(encoding="utf-8")
artifact_marker = '_write_step_artifact(\n run_dir, 12, "slot_payload"'
helper_marker = '_emit_diag_zones_shape("Step 12 slot_payload emit", zones_data)'
artifact_pos = src.find(artifact_marker)
helper_pos = src.find(helper_marker)
assert artifact_pos != -1, "Step 12 slot_payload artifact write not found"
assert helper_pos != -1, "Step 12 DIAG helper call not found"
assert helper_pos > artifact_pos, (
"Step 12 DIAG helper call must appear after the slot_payload artifact write "
f"(artifact_pos={artifact_pos}, helper_pos={helper_pos})"
)
def test_step13_entry_call_site_invokes_helper_inside_render_slide():
src = Path(phase_z2_pipeline.__file__).read_text(encoding="utf-8")
render_slide_def = src.find("def render_slide(")
assert render_slide_def != -1, "render_slide definition not found"
# Bound the search to the function body — find next def or class after it.
next_def = src.find("\ndef ", render_slide_def + len("def render_slide("))
body = src[render_slide_def:next_def if next_def != -1 else len(src)]
assert '_emit_diag_zones_shape(\n "Step 13 render_slide entry"' in body, (
"Step 13 DIAG helper call not found inside render_slide()"
)
# ─── unconditional contract (no env-gate) ────────────────────────
def test_diag_helper_has_no_env_gate(monkeypatch, capsys):
# Stage 1 contract: diag is unconditional. Setting any plausible
# "verbose off" env var must not silence the line. We test the most
# common gate names a future contributor might be tempted to add.
for env_name in (
"PHASE_Z_DIAG_VERBOSE",
"DIAG_VERBOSE",
"VERBOSE",
"DEBUG",
"PYTHON_NO_DIAG",
):
monkeypatch.setenv(env_name, "0")
_emit_diag_zones_shape("Step 12 slot_payload emit", [])
out = capsys.readouterr().out
assert "[DIAG] phase_z2 Step 12 slot_payload emit" in out