"""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가 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 승인", "· 승인" not in h and 'class="ok">승인' 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)