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