#!/usr/bin/env python3 """P1-B tests — /consult engagement 분기(비즈니스 vs 문서-컨설팅) + 렌더 열화(degraded) 표시. 두 결함을 회귀 고정한다: #15 consult.md가 문서 컨설팅이면 FAM-DOC-CONSULT를 고른다고 '설명'만 하고 실제 FRAME/ANALYZE/ SYNTHESIZE 절차는 비즈니스 5분과로 하드코딩 → 문서 요청이 조용히 비즈니스 분과로 샘. #16(부분) render_consult가 D2/Mermaid/exhibit 렌더 실패 시 코드-텍스트 폴백 SVG를 쓰면서 '성공' 처리(열화 은폐). standalone(파이테스트 불필요). exit 0 = all pass. 실행: CLAUDE_PROJECT_DIR="$PWD" python3 .claude/tests/test_p1_consult.py """ import json import os import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) HOOKS = os.path.join(ROOT, ".claude", "hooks") CMDS = os.path.join(ROOT, ".claude", "commands") FIX = os.path.join(ROOT, ".claude", "tests", "fixtures", "p1_consult") PY = sys.executable os.makedirs(FIX, exist_ok=True) passed, failed = 0, 0 def check(name, ok): global passed, failed if ok: passed += 1 print(f" PASS {name}") else: failed += 1 print(f" FAIL {name}") def run(script, args=None, env=None): e = dict(os.environ) e["CLAUDE_PROJECT_DIR"] = ROOT if env: e.update(env) return subprocess.run([PY, os.path.join(HOOKS, script)] + (args or []), capture_output=True, text=True, env=e) def write(path, text): with open(path, "w") as f: f.write(text) return path def between(text, start, end=None): i = text.find(start) if i < 0: return "" i += len(start) j = text.find(end, i) if end else len(text) return text[i:j if j >= 0 else len(text)] # ==================================================================== #15: engagement 분기 print("== consult.md engagement 분기 (business vs doc-consulting) ==") MD = open(os.path.join(CMDS, "consult.md")).read() DOC_AGENTS = ["doc-lead", "doc-writer", "doc-ia", "doc-visual", "doc-edu"] BIZ_AGENTS = ["consult-em", "consult-strat", "consult-ops", "consult-org", "consult-digital", "consult-fin"] # (1) engagement 데이터 블록에 두 유형이 데이터로 존재 check("engagement 데이터 블록에 business + doc-consulting 두 유형", "business:" in MD and "doc-consulting:" in MD) check("두 family가 데이터로 바인딩됨", "FAM-CONSULTING" in MD and "FAM-DOC-CONSULT" in MD) check("doc-consulting 유형이 doc-family 5개 에이전트를 명시", all(a in MD for a in DOC_AGENTS)) check("business 유형이 consult-family 6개 에이전트를 명시(기존 경로 보존)", all(a in MD for a in BIZ_AGENTS)) # (2) 절차(FRAME/ANALYZE/SYNTHESIZE)가 doc 경로를 실제 배선 — 설명이 아니라 절차 # 원본은 절차에 consult-* 만 있었음 → doc 에이전트가 절차 안에 있으면 회귀 방지 성립. PROC = between(MD, "## 절차") check("절차 섹션이 존재", len(PROC) > 0 and ("FRAME" in PROC and "ANALYZE" in PROC and "SYNTHESIZE" in PROC)) check("절차 안에서 doc-family 5개가 실제 배선됨(하드코딩 비즈니스로 안 샘)", all(a in PROC for a in DOC_AGENTS)) check("절차 안에서 business 5분과도 유지(대칭 보존)", all(a in PROC for a in ["consult-strat", "consult-ops", "consult-org", "consult-digital", "consult-fin"])) # (3) 단계별 대칭 배선: FRAME=doc-lead, ANALYZE=doc-writer/ia/visual/edu, SYNTHESIZE=doc-lead FRAME = between(PROC, "① FRAME", "② ANALYZE") ANALYZE = between(PROC, "② ANALYZE", "③ SYNTHESIZE") SYNTH = between(PROC, "③ SYNTHESIZE", "④ RENDER") check("① FRAME 단계가 doc-lead를 lead로 배선", "doc-lead" in FRAME) check("② ANALYZE 단계가 doc 4분과(writer/ia/visual/edu) fan-out 배선", all(a in ANALYZE for a in ["doc-writer", "doc-ia", "doc-visual", "doc-edu"])) check("③ SYNTHESIZE 단계가 doc-lead를 종합자로 배선", "doc-lead" in SYNTH) # 비즈니스 경로도 각 단계에 여전히 존재(대칭) check("① FRAME 단계에 consult-em(business lead) 유지", "consult-em" in FRAME) check("③ SYNTHESIZE 단계에 consult-em(business 종합자) 유지", "consult-em" in SYNTH) # ==================================================================== #16: 렌더 열화 표시 print("== render_consult 렌더 열화(degraded) 표시 ==") MARKER = "ORGOS-RENDER-DEGRADED" def _report(title, exhibit_line): return ( "synthesized-by: DOC-LEAD\ntitle: T\n" "report-header:\n bottom-line: bl.\n decision-needed: { needed: false }\n" " confidence: { value: Med, derived-from: evidence }\n risks: []\n" " evidence:\n - { source-uri: org-os/00-role-registry/lens-registry.yaml, grade: E3 }\n" f"storyline:\n title: {title}\n slides:\n" " - action-title: 렌더 열화 감지 테스트\n" f" exhibit: {exhibit_line}\n body: [b]\n" ) def _img_svgs(outdir): imgdir = os.path.join(outdir, "img") if not os.path.isdir(imgdir): return [] return [os.path.join(imgdir, f) for f in os.listdir(imgdir) if f.endswith(".svg")] def _degraded_case(name, title, exhibit_line, envkey): rep = write(os.path.join(FIX, name + ".report.yaml"), _report(title, exhibit_line)) outdir = os.path.join(FIX, name + "-out") r = run("render_consult.py", [rep, "--outdir", outdir], env={envkey: "1"}) # 성공-with-fallback 은 exit 0 유지(test_enforcement 계약 보존) — 단 degraded로 표시 check(f"[{name}] 폴백이어도 exit 0 유지(파이프라인 안 깨짐)", r.returncode == 0) check(f"[{name}] stderr에 DEGRADED 경고", "DEGRADED" in r.stderr) check(f"[{name}] stdout 마지막 마커 RENDER_STATUS: DEGRADED", "RENDER_STATUS: DEGRADED" in r.stdout) # 기계 감지 신호 ①: render.json 플래그 meta_path = os.path.join(outdir, title.lower() + "-render.json") ok_meta = False if os.path.exists(meta_path): meta = json.load(open(meta_path)) ok_meta = (meta.get("status") == "degraded" and meta.get("degraded") is True and len(meta.get("degraded_exhibits", [])) >= 1) check(f"[{name}] render.json 이 degraded 플래그로 기록", ok_meta) # 기계 감지 신호 ②: 폴백 SVG 파일에 마커 심김 svgs = _img_svgs(outdir) check(f"[{name}] 폴백 SVG가 여전히 생성됨(렌더 안 깨짐)", len(svgs) >= 1) check(f"[{name}] 폴백 SVG에 열화 마커(ORGOS-RENDER-DEGRADED) 존재", any(MARKER in open(s).read() for s in svgs)) # HTML 덱은 여전히 embed (기존 test_enforcement 계약 보존) dhtml = os.path.join(outdir, title.lower() + "-deck.html") check(f"[{name}] HTML 덱에 inline embed 유지", os.path.exists(dhtml) and " b: dep", layout: dagre }', "RENDER_CONSULT_NO_D2") # mermaid 렌더 미가용 강제 → degraded _degraded_case("mermfail", "MermFail", '{ type: mermaid, code: "flowchart TB\\n A --> B" }', "RENDER_CONSULT_NO_MMDC") # 통제군: 정상 아키타입(waterfall)은 열화 아님 — 성공 렌더는 기존대로. print("== 통제군: 성공 렌더는 degraded 아님(기존 동작 보존) ==") _okrep = write(os.path.join(FIX, "okcase" + ".report.yaml"), _report("OkCase", "{ type: waterfall, unit: '%', start: ['현재',35], deltas: [['역전',18]], end: ['목표',72] }")) _okout = os.path.join(FIX, "ok-out") _ro = run("render_consult.py", [_okrep, "--outdir", _okout]) check("[ok] 성공 렌더 exit 0", _ro.returncode == 0) check("[ok] stdout 마커 RENDER_STATUS: OK", "RENDER_STATUS: OK" in _ro.stdout) check("[ok] stderr에 DEGRADED 경고 없음", "DEGRADED" not in _ro.stderr) _okmeta = os.path.join(_okout, "okcase-render.json") check("[ok] render.json status=ok/degraded=false", os.path.exists(_okmeta) and json.load(open(_okmeta)).get("status") == "ok" and json.load(open(_okmeta)).get("degraded") is False) check("[ok] 성공 SVG에는 열화 마커 없음", all(MARKER not in open(s).read() for s in _img_svgs(_okout))) # cleanup import shutil as _sh # noqa: E402 _sh.rmtree(FIX, ignore_errors=True) print(f"\n{passed} passed, {failed} failed") sys.exit(1 if failed else 0)