190 lines
8.7 KiB
Python
190 lines
8.7 KiB
Python
#!/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 덱은 여전히 <svg> embed (기존 test_enforcement 계약 보존)
|
|
dhtml = os.path.join(outdir, title.lower() + "-deck.html")
|
|
check(f"[{name}] HTML 덱에 inline <svg> embed 유지",
|
|
os.path.exists(dhtml) and "<svg" in open(dhtml).read())
|
|
# #16: --strict면 같은 degraded 산출이 비영점(발표/게시 하드 게이트) — 기본 exit 0과 대비
|
|
rs = run("render_consult.py", [rep, "--outdir", outdir + "-strict", "--strict"], env={envkey: "1"})
|
|
check(f"[{name}] --strict → degraded면 비영점 종료(게시 게이트)", rs.returncode != 0)
|
|
|
|
|
|
# d2 렌더 미가용 강제 → degraded
|
|
_degraded_case("d2fail", "D2Fail",
|
|
'{ type: d2, code: "direction: right\\n a -> 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)
|