feat(infra): per-frame Jinja smoke harness with StrictUndefined (IMP-04 #4)
- Add scripts/smoke_frame_render.py for IMP-04 scope-lock §11+§13:
isolated StrictUndefined Jinja partial render gate, separated from
production render_slide() permissive behavior
- CLI: --self-check runs every bundled fixture; positional template_id
takes payload via --payload <json> / stdin / fixture
- Bundled mock payloads for the 3 existing active frames match real
builder output shape; all 3 partials PASS self-check
- New frame activation gate (per-frame 6-step Step 5): partial must
pass smoke render with a complete mock that mirrors the builder's
output dict (optional fields supplied as empty/falsy so {% if %}
guards still work under StrictUndefined)
- Exit codes: 0=all pass, 1=at least one fail, 2=invalid input
Latent finding (out of IMP-04 scope, surfaced for the record):
bim_issues_quadrant_four partial references slot_payload.quadrant_N_headline,
but _build_quadrant_flat_slots() only produces quadrant_N_label and
quadrant_N_body. The headline div therefore never renders in production.
Either dead reference or a builder extension that was never landed —
reconcile in a follow-up axis, not in IMP-04 catalog expansion.
production render path (phase_z2_pipeline.render_slide) unchanged.
Refs Gitea #4 (IMP-04 A-2 Catalog 확장 — infra commit)
This commit is contained in:
266
scripts/smoke_frame_render.py
Normal file
266
scripts/smoke_frame_render.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""IMP-04 per-frame Jinja smoke harness (StrictUndefined).
|
||||
|
||||
scope-lock 16 조건 (Gitea #4) §11 / §13 :
|
||||
- smoke = isolated Jinja partial render with StrictUndefined
|
||||
- production render path (`phase_z2_pipeline.render_slide`) 미변경 — 본 harness 만 strict
|
||||
- builder output keys ↔ partial Jinja variables 정합 확인이 본 harness 의 목적
|
||||
- asset path / undefined variable / template syntax 실패 시 즉시 error
|
||||
|
||||
Usage :
|
||||
# sanity check existing frames (mock payloads bundled below)
|
||||
python scripts/smoke_frame_render.py --self-check
|
||||
|
||||
# check a specific template with mock payload from stdin
|
||||
python scripts/smoke_frame_render.py three_parallel_requirements < mock.json
|
||||
|
||||
# check all template_ids in `templates/phase_z2/families/` against bundled mocks
|
||||
python scripts/smoke_frame_render.py --self-check
|
||||
|
||||
Exit codes :
|
||||
0 = all renders succeeded (StrictUndefined passed)
|
||||
1 = at least one render failed
|
||||
2 = invalid input (template_id 미존재 등)
|
||||
|
||||
본 harness 는 IMP-04 의 per-frame 6-step gate Step 5 의 자동 실행 단위.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from jinja2 import (
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
StrictUndefined,
|
||||
TemplateError,
|
||||
UndefinedError,
|
||||
select_autoescape,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2"
|
||||
FAMILIES_DIR = TEMPLATE_DIR / "families"
|
||||
|
||||
|
||||
# ─── Mock payloads (existing 3 frames — sanity baseline) ────────
|
||||
|
||||
# mock = real builder output shape (refer mapper.py builders).
|
||||
# StrictUndefined requires every referenced attribute to exist; optional fields
|
||||
# must be present with empty/None values so `{% if x %}` evaluates falsy.
|
||||
_MOCK_THREE_PARALLEL = {
|
||||
"title": "1. DX 시행을 위한 필수 요건",
|
||||
"pillars": [
|
||||
{
|
||||
"label": "기술 (Technology)",
|
||||
"label_main": "기술",
|
||||
"label_paren": "Technology",
|
||||
"color_class": "tech",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "디지털 도구",
|
||||
"text_lines": [
|
||||
{"text": "BIM 활용", "indent": 0},
|
||||
{"text": "클라우드 협업", "indent": 0},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "사람 (People)",
|
||||
"label_main": "사람",
|
||||
"label_paren": "People",
|
||||
"color_class": "people",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "역량 강화",
|
||||
"text_lines": [
|
||||
{"text": "전문가 양성", "indent": 0},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "자연 (Nature)",
|
||||
"label_main": "자연",
|
||||
"label_paren": "Nature",
|
||||
"color_class": "nature",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "환경 적응",
|
||||
"text_lines": [
|
||||
{"text": "지역적 제약 고려", "indent": 0},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_MOCK_PROCESS_PRODUCT = {
|
||||
"title": "2. Process의 혁신과 Product의 변화",
|
||||
"banner_left": "Process",
|
||||
"banner_right": "Product",
|
||||
"process": {
|
||||
"sections": [
|
||||
{"title": "과정 1", "transforms": [], "text_lines": [{"text": "AS-IS 정리", "indent": 0}]},
|
||||
{"title": "과정 2", "transforms": [], "text_lines": [{"text": "TO-BE 전환", "indent": 0}]},
|
||||
{"title": "과정 3", "transforms": [], "text_lines": [{"text": "검증 단계", "indent": 0}]},
|
||||
],
|
||||
},
|
||||
"product": {
|
||||
"sections": [
|
||||
{"title": "결과 1", "transforms": [], "text_lines": [{"text": "BIM 모델", "indent": 0}], "footnote": "", "sub_title": ""},
|
||||
{"title": "결과 2", "transforms": [], "text_lines": [{"text": "통합 협업", "indent": 0}], "footnote": "", "sub_title": ""},
|
||||
{"title": "결과 3", "transforms": [], "text_lines": [{"text": "실시간 검증", "indent": 0}], "footnote": "", "sub_title": ""},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
_MOCK_QUADRANT = {
|
||||
"title": "BIM 도입 4 문제",
|
||||
"center_quote": "",
|
||||
"quadrant_1_label": "기술 부족",
|
||||
"quadrant_1_headline": "",
|
||||
"quadrant_1_body": [{"text": "디지털 도구 미숙", "indent": 0}],
|
||||
"quadrant_2_label": "인력 부족",
|
||||
"quadrant_2_headline": "",
|
||||
"quadrant_2_body": [{"text": "전문가 부재", "indent": 0}],
|
||||
"quadrant_3_label": "프로세스",
|
||||
"quadrant_3_headline": "",
|
||||
"quadrant_3_body": [{"text": "업무 흐름 단절", "indent": 0}],
|
||||
"quadrant_4_label": "표준화",
|
||||
"quadrant_4_headline": "",
|
||||
"quadrant_4_body": [{"text": "가이드 부재", "indent": 0}],
|
||||
}
|
||||
|
||||
SELF_CHECK_FIXTURES: dict[str, dict] = {
|
||||
"three_parallel_requirements": _MOCK_THREE_PARALLEL,
|
||||
"process_product_two_way": _MOCK_PROCESS_PRODUCT,
|
||||
"bim_issues_quadrant_four": _MOCK_QUADRANT,
|
||||
}
|
||||
|
||||
|
||||
# ─── Core ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_env() -> Environment:
|
||||
"""StrictUndefined Jinja env — production render path 와 동등 loader,
|
||||
단 undefined behavior 만 strict.
|
||||
"""
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
undefined=StrictUndefined,
|
||||
)
|
||||
|
||||
|
||||
def smoke_render(template_id: str, slot_payload: dict) -> tuple[bool, str]:
|
||||
"""Isolated StrictUndefined Jinja render.
|
||||
|
||||
Returns (ok, html_or_error_text).
|
||||
"""
|
||||
env = _make_env()
|
||||
try:
|
||||
partial = env.get_template(f"families/{template_id}.html")
|
||||
except Exception as exc: # noqa: BLE001 — surface any loader error
|
||||
return False, f"TemplateLoad: {exc!r}"
|
||||
|
||||
try:
|
||||
html = partial.render(slot_payload=slot_payload)
|
||||
except UndefinedError as exc:
|
||||
return False, f"UndefinedError (missing variable): {exc}"
|
||||
except TemplateError as exc:
|
||||
return False, f"TemplateError: {exc}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, f"RenderError: {exc!r}"
|
||||
return True, html
|
||||
|
||||
|
||||
def list_existing_partials() -> list[str]:
|
||||
"""Return template_id list (filename stems without .html) under families/."""
|
||||
return sorted(p.stem for p in FAMILIES_DIR.glob("*.html"))
|
||||
|
||||
|
||||
# ─── CLI ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cmd_self_check() -> int:
|
||||
"""Run smoke render against every bundled fixture and report.
|
||||
|
||||
Exit 0 if all PASS, 1 otherwise. Frames with no fixture are SKIPPED
|
||||
(reported separately so the per-frame gate is explicit).
|
||||
"""
|
||||
existing = list_existing_partials()
|
||||
print(f"== smoke harness self-check ({len(existing)} partial(s) found) ==")
|
||||
fail_count = 0
|
||||
skip_count = 0
|
||||
for tpl in existing:
|
||||
if tpl not in SELF_CHECK_FIXTURES:
|
||||
print(f" SKIP {tpl} (no bundled fixture)")
|
||||
skip_count += 1
|
||||
continue
|
||||
ok, msg = smoke_render(tpl, SELF_CHECK_FIXTURES[tpl])
|
||||
if ok:
|
||||
print(f" PASS {tpl} ({len(msg)} chars)")
|
||||
else:
|
||||
print(f" FAIL {tpl} → {msg}")
|
||||
fail_count += 1
|
||||
|
||||
# Also surface fixtures with no corresponding partial (catches typos)
|
||||
for fixture in SELF_CHECK_FIXTURES:
|
||||
if fixture not in existing:
|
||||
print(f" ORPHAN {fixture} (fixture but no partial)")
|
||||
fail_count += 1
|
||||
|
||||
print(f"-- summary: PASS={len(existing) - fail_count - skip_count} "
|
||||
f"FAIL={fail_count} SKIP={skip_count} --")
|
||||
return 0 if fail_count == 0 else 1
|
||||
|
||||
|
||||
def _cmd_one(template_id: str, payload_path: Optional[Path]) -> int:
|
||||
if payload_path is not None:
|
||||
slot_payload = json.loads(payload_path.read_text(encoding="utf-8"))
|
||||
elif not sys.stdin.isatty():
|
||||
slot_payload = json.load(sys.stdin)
|
||||
elif template_id in SELF_CHECK_FIXTURES:
|
||||
slot_payload = SELF_CHECK_FIXTURES[template_id]
|
||||
print(f"[info] using bundled fixture for {template_id}", file=sys.stderr)
|
||||
else:
|
||||
print(f"error: no payload provided for '{template_id}' "
|
||||
f"(pipe JSON via stdin or use --payload)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ok, msg = smoke_render(template_id, slot_payload)
|
||||
if ok:
|
||||
print(f"PASS {template_id} ({len(msg)} chars rendered)", file=sys.stderr)
|
||||
sys.stdout.write(msg)
|
||||
return 0
|
||||
print(f"FAIL {template_id} → {msg}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="IMP-04 per-frame Jinja smoke harness (StrictUndefined).",
|
||||
)
|
||||
parser.add_argument("template_id", nargs="?",
|
||||
help="frame template_id (omit when --self-check).")
|
||||
parser.add_argument("--self-check", action="store_true",
|
||||
help="render every bundled fixture and report.")
|
||||
parser.add_argument("--payload", type=Path, default=None,
|
||||
help="JSON file with slot_payload (else stdin or fixture).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_check:
|
||||
return _cmd_self_check()
|
||||
if args.template_id is None:
|
||||
parser.print_usage(sys.stderr)
|
||||
return 2
|
||||
return _cmd_one(args.template_id, args.payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user