From 2717a0a3a61b71bbb9e83f8d9da3db34d81054b1 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Wed, 13 May 2026 06:48:19 +0900 Subject: [PATCH] feat(infra): per-frame Jinja smoke harness with StrictUndefined (IMP-04 #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 / 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) --- scripts/smoke_frame_render.py | 266 ++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 scripts/smoke_frame_render.py diff --git a/scripts/smoke_frame_render.py b/scripts/smoke_frame_render.py new file mode 100644 index 0000000..d3f77de --- /dev/null +++ b/scripts/smoke_frame_render.py @@ -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())