- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
155 lines
7.7 KiB
Python
155 lines
7.7 KiB
Python
"""Test of improvement #34-5 (2026-10-20): a batch run that did not finish must not look like a finished one. No Rhino. Own data root
|
|
검증/batch_notice_test_data (recreated): a copy of Ungok P4 whose run folder is built from the json files of its real runs (copied) with chosen file times.
|
|
Acceptance: the gates page says WHY the run stopped (error / FAIL / blocked / killed), marks the results that are older than the run, shows the partial
|
|
results computed before it stopped (and only then), labels an approval of an earlier input as void, and shows nothing of this for a finished run."""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
APP = Path(__file__).resolve().parents[1]
|
|
D = APP / "data"
|
|
TD = APP / "검증" / "batch_notice_test_data"
|
|
if TD.exists():
|
|
shutil.rmtree(TD)
|
|
sys.path.insert(0, str(APP / "tools"))
|
|
from testlib import verified_pier # noqa: E402
|
|
src = verified_pier("운곡교P4")
|
|
if src is None or not (src / "runs" / "current" / "G1_gate.json").exists():
|
|
print("SKIP no real run of 운곡교P4")
|
|
print("RESULT PASS")
|
|
sys.exit(0)
|
|
shutil.copytree(src, TD / "운곡교P4", ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf", "confirm.json"))
|
|
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
|
|
os.environ["PIERAPP_DATA"] = str(TD)
|
|
sys.path.insert(0, str(APP / "web"))
|
|
sys.path.insert(0, str(APP))
|
|
import app as webapp # noqa: E402
|
|
import store # noqa: E402
|
|
|
|
assert Path(str(store.DATA)) == TD
|
|
ok = True
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
global ok
|
|
ok &= bool(cond)
|
|
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
|
|
|
|
|
|
p = store.Pier("운곡교P4")
|
|
run = p.run_dir
|
|
run.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def fresh():
|
|
for f in run.glob("*.json"):
|
|
f.unlink()
|
|
for f in (src / "runs" / "current").glob("*.json"):
|
|
if f.name.startswith("G") and not f.name.endswith(("_negH.json", "_negV.json")) or f.name == "approvals.json":
|
|
shutil.copy2(f, run / f.name)
|
|
|
|
|
|
def at(path, ago_s):
|
|
t = time.time() - ago_s
|
|
os.utime(path, (t, t))
|
|
|
|
|
|
def batch(state, **kw):
|
|
b = {"state": state, "by": "시험자", "pid": os.getpid(), "started": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 600)),
|
|
"at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 60)), "log": ["G1~G5 단일 세션 실행", "[batch] one Rhino session"]}
|
|
b.update(kw)
|
|
store.wjson(run / "batch.json", b)
|
|
|
|
|
|
def page():
|
|
return webapp.app.test_client().get("/pier/운곡교P4/gates").get_data(as_text=True)
|
|
|
|
|
|
# ---- A. error: old recorded results, newer checker outputs
|
|
fresh()
|
|
for g in ("G1", "G2", "G3", "G4", "G5"):
|
|
at(run / f"{g}_gate.json", 20000) # recorded long ago (an earlier run)
|
|
at(run / f"{g}.json", 300) # checker output of the aborted run (newer)
|
|
batch("중단", reason="error", error="RuntimeError: display: hidden_ids not found ['P1-CP']")
|
|
h = page()
|
|
check("error: the page says the run did not finish and why (error text shown)", 'id="batch-aborted"' in h and "오류" in h and "hidden_ids not found" in h)
|
|
check("error: results older than the run are marked 이전 실행 결과", h.count("이전 실행 결과") >= 5)
|
|
check("error: partial results of the aborted run are shown per gate and labelled as not used for approval", 'id="partial-G1"' in h and "임시 결과" in h and "승인·보고서에는 쓰이지 않습니다" in h)
|
|
check("error: the partial block reports the checker's own pass / total", f"{json.loads((run / 'G1.json').read_text(encoding='utf-8'))['summary']['passed']} / " in h)
|
|
check("partial_result() returns the counts and the failed items", p.partial_result("G3") and p.partial_result("G3")["total"] and isinstance(p.partial_result("G3")["fails"], list))
|
|
|
|
# ---- B. the recorded result is NEWER than the checker output (a normal finished gate): no partial block, no 'older' mark
|
|
at(run / "G1_gate.json", 100)
|
|
at(run / "G1.json", 300)
|
|
check("a recorded result newer than the checker output is not a partial result", p.partial_result("G1") is None)
|
|
|
|
# ---- C. FAIL stop
|
|
batch("중단", reason="fail", gate="G3")
|
|
check("fail: names the gate that stopped the run", "G3</b>가 FAIL" in page())
|
|
|
|
# ---- D. blocked / killed
|
|
batch("중단", reason="blocked")
|
|
check("blocked: says the run could not start", "시작하지 못했습니다" in page())
|
|
batch("실행 중", pid=os.getpid() + 1) # a run of another (dead) server process
|
|
h = page()
|
|
check("killed: a 실행 중 record of another process is shown as 중단됨 with its reason", "서버가 멈추거나" in h and "중단됨" in h)
|
|
|
|
# ---- E. finished run: nothing of the abort notice
|
|
batch("완료")
|
|
h = page()
|
|
check("finished run: no abort banner, no partial block, no 'older' mark", 'id="batch-aborted"' not in h and 'id="partial-' not in h and "이전 실행 결과 —" not in h)
|
|
|
|
# ---- F. an approval of an earlier input is labelled void
|
|
ap = store.rjson(run / "approvals.json", {})
|
|
for g in ap:
|
|
ap[g]["inputs_sha"] = "0000000000000000"
|
|
store.wjson(run / "approvals.json", ap)
|
|
h = page()
|
|
st = p.gate_status("G1")
|
|
check("approval of an earlier input: approval_stale and the label 승인 기록은 이전 입력 것(무효)", st["approval_stale"] and not st["approved"] and "승인 기록은 이전 입력 것(무효)" in h)
|
|
check("the void approval is not shown as a plain 승인", "· 승인</h2>" not in h and 'class="ok">승인</span>' not in h)
|
|
|
|
# ---- G. the real worker (app._batch) with a fake Rhino session: it records started + a reason on every stop path
|
|
fresh()
|
|
need = ["G1", "G2", "G3", "G4", "G5"]
|
|
for f in run.glob("*_gate.json"):
|
|
f.unlink() # no current PASS result -> the worker has to start a session
|
|
for f in run.glob("approvals.json"):
|
|
f.unlink()
|
|
webapp.gate_blockers = lambda pp, g: []
|
|
|
|
|
|
def boom(*a, **k):
|
|
raise RuntimeError("fake Rhino failure")
|
|
|
|
|
|
webapp.run_batch_session = boom
|
|
webapp._batch(p, "시험자", need)
|
|
b = store.rjson(run / "batch.json")
|
|
check("worker: a failure inside the Rhino session -> 중단 with reason error, the last traceback line, and a start time",
|
|
b["state"] == "중단" and b["reason"] == "error" and "fake Rhino failure" in b["error"] and b.get("started"), {k: b.get(k) for k in ("state", "reason", "error", "started")})
|
|
|
|
|
|
def fake_session(gates, *a, **k):
|
|
rep = {"verdict": "PASS", "summary": {"passed": 3, "total": 3}, "planned_fails": [], "unplanned_fails": [], "negative": {}, "mode": "batch"}
|
|
return {g: dict(rep, verdict="FAIL" if g == "G2" else "PASS") for g in gates}
|
|
|
|
|
|
webapp.run_batch_session = fake_session
|
|
webapp._batch(p, "시험자", need)
|
|
b = store.rjson(run / "batch.json")
|
|
check("worker: a FAIL result -> 중단 with reason fail and the failing gate", b["state"] == "중단" and b.get("reason") == "fail" and b.get("gate") == "G2", {k: b.get(k) for k in ("state", "reason", "gate")})
|
|
g1 = store.rjson(run / "G1_gate.json")
|
|
check("worker: every recorded gate result carries its own time (at)", g1.get("at") and store.rjson(run / "G2_gate.json").get("at"))
|
|
check("worker: G1 (PASS before the failing gate) was approved, G2 was not", p.approvals().get("G1", {}).get("decision") == "승인" and "G2" not in p.approvals())
|
|
webapp.gate_blockers = lambda pp, g: ["G2: 입력 묶음 미확정"] if g == "G2" else []
|
|
webapp._batch(p, "시험자", need)
|
|
b = store.rjson(run / "batch.json")
|
|
check("worker: a blocked run -> 중단 with reason blocked", b["state"] == "중단" and b.get("reason") == "blocked", {k: b.get(k) for k in ("state", "reason")})
|
|
|
|
print("RESULT", "PASS" if ok else "FAIL")
|
|
sys.exit(0 if ok else 1)
|