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, "ctx") + msg = str(exc_info.value) + assert "..." in msg # truncation marker present + + +def test_error_message_cites_context_label_verbatim(): + html = '' + 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 ` 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