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
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:
@@ -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) {
|
||||
|
||||
117
Front/client/tests/handle_generate_diag.test.ts
Normal file
117
Front/client/tests/handle_generate_diag.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 강제.
|
||||
|
||||
135
tests/phase_z2/test_phase_z2_diag_invalid_char.py
Normal file
135
tests/phase_z2/test_phase_z2_diag_invalid_char.py
Normal 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 ``&`` (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 = '<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&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 "&" in msg
|
||||
assert "src" in msg
|
||||
|
||||
|
||||
def test_escaped_apostrophe_in_href_raises():
|
||||
html = '<a href="docs/it'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 "'" in msg
|
||||
assert "href" in msg
|
||||
|
||||
|
||||
def test_escaped_ampersand_in_url_raises():
|
||||
html = "<style>div { background: url('img&.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 & in copy) is not a path signal.
|
||||
html = "<p>AT&T 'quoted' 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
|
||||
116
tests/phase_z2/test_phase_z2_diag_render_assertions.py
Normal file
116
tests/phase_z2/test_phase_z2_diag_render_assertions.py
Normal 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
|
||||
92
tests/phase_z2/test_phase_z2_diag_smoke_general.py
Normal file
92
tests/phase_z2/test_phase_z2_diag_smoke_general.py
Normal 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"] == []
|
||||
223
tests/phase_z2/test_phase_z2_diag_terminal_logs.py
Normal file
223
tests/phase_z2/test_phase_z2_diag_terminal_logs.py
Normal 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
|
||||
Reference in New Issue
Block a user