"""Test of the batch features (2026-10-12): bulk confirmation and G1..G5 run with auto-approval. Own data root (검증/web_batch_test_data, recreated) with the Ungok P1 params/expected as a pier; real Rhino runs. Checks: name required; bulk confirm confirms exactly the required unconfirmed sections; single run refused while a batch runs; a wrong input stops the batch at G1 without approval; a correct input approves G1..G5 under the given name with the auto note. """ import json import os import shutil import sys import time from pathlib import Path APP = Path(__file__).resolve().parents[1] TD = APP / "검증" / "web_batch_test_data" if TD.exists(): shutil.rmtree(TD) (TD / "배치P1").mkdir(parents=True) for f in ("params.json", "expected.json"): shutil.copy2(APP / "data" / "운곡교P1" / f, TD / "배치P1" / f) os.environ["PIERAPP_DATA"] = str(TD) sys.path.insert(0, str(APP / "web")) import app as webapp # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent)) from testlib import confirm_required # noqa: E402 import store # noqa: E402 P, BY = "배치P1", "시험자" results = [] def check(name, ok, detail=""): results.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": str(detail)[:300]}) print("PASS" if ok else "FAIL", name, str(detail)[:200], flush=True) def html(r): return r.get_data(as_text=True) def wait_batch(limit=1500): t0 = time.time() while time.time() - t0 < limit: b = webapp.batch_state(store.Pier(P)) if b and b["state"] != "실행 중": return b, round(time.time() - t0) time.sleep(5) return webapp.batch_state(store.Pier(P)), round(time.time() - t0) def main(): c = webapp.app.test_client() pier = store.Pier(P) need0 = webapp.unconfirmed(pier) r = c.post(f"/pier/{P}/section/{need0[0][0]}/{need0[0][1]}/confirm", data={"by": "", "why": "시험"}, follow_redirects=True) check("confirm needs a name", "확정자 이름" in html(r) and webapp.unconfirmed(store.Pier(P)) == need0) confirm_required(c, webapp, store, P, BY) p = store.Pier(P) conf = p.confirmations() check("confirm: all required sections confirmed", not webapp.unconfirmed(p) and len(need0) == 23 and all(conf[f"{f}:{k}"]["by"] == BY for f, k in need0), f"{len(need0)} sections") check("confirm: sections not required stay unconfirmed", "params:_meta" not in conf) r = c.get(f"/pier/{P}") check("pier page shows all confirmed", "모두 확정되어 있습니다" in html(r)) r = c.post(f"/pier/{P}/gates/run_all", data={"by": ""}, follow_redirects=True) check("batch run needs a name", "판정자 이름" in html(r)) # wrong input: pile length +5 mm -> G1 FAIL, batch stops, nothing approved pr = p.load("params") pile = dict(pr["piles"], length=pr["piles"]["length"] + 5) c.post(f"/pier/{P}/section/params/piles", data={"value": json.dumps(pile)}) confirm_required(c, webapp, store, P, BY) r = c.post(f"/pier/{P}/gates/run_all", data={"by": BY}, follow_redirects=True) r2 = c.post(f"/pier/{P}/gate/G2/run", follow_redirects=True) check("single run refused while the batch runs", "일괄 실행 중에는" in html(r2)) b, secs = wait_batch() p = store.Pier(P) check("wrong input: batch stops at G1 FAIL", b["state"] == "중단" and any("G1 FAIL" in m for m in b["log"]), b["log"]) check("wrong input: nothing approved", not p.approvals(), p.approvals()) # correct input: G1..G5 approved under the given name with the auto note pile["length"] -= 5 c.post(f"/pier/{P}/section/params/piles", data={"value": json.dumps(pile)}) confirm_required(c, webapp, store, P, BY) c.post(f"/pier/{P}/gates/run_all", data={"by": BY}) b, secs = wait_batch() p = store.Pier(P) ap = p.approvals() check("batch completes", b["state"] == "완료", f"{b['log']} {secs}s") check("G1..G5 approved by the given name with the auto note", all(p.gate_status(g)["approved"] and ap[g]["by"] == BY and ap[g]["note"] == webapp.AUTO_NOTE for g in ("G1", "G2", "G3", "G4", "G5")), ap) reps = {g: p.gate_status(g)["report"] for g in ("G1", "G2", "G3", "G4", "G5")} check("batch: one session, gate checks only (mode batch, reload/unchanged skipped)", all(r["mode"] == "batch" and r["reload"]["ok"] is None and r["unchanged"]["ok"] is None for r in reps.values()) and len({r["job_id"] for r in reps.values()}) == 1, {g: r["mode"] for g, r in reps.items()}) run = TD / P / "runs" / "current" check("batch: only G5 model saved", (run / "G5.3dm").exists() and not any((run / f"G{i}.3dm").exists() for i in range(1, 5))) check("batch: G1 download refused, G5 download served", c.get(f"/pier/{P}/download/G1").status_code == 404 and c.get(f"/pier/{P}/download/G5").status_code == 200) check("batch: session time", True, f"{reps['G5']['session_secs']}s for G1..G5 (individual runs ~330s)") # the single-session model equals the model of individual gate runs (same FAST fingerprint mode) sys.path.insert(0, str(APP)); sys.path.insert(0, str(APP / "tools")) from runner import run_job from compare_fingerprints import compare fp = run / "G5_session_fp.json" run_job([{"name": "fp", "script": APP / "engine" / "rhino" / "fingerprint.py", "globals": {"MODEL_PATH": str(run / "G5.3dm"), "OUT_PATH": str(fp), "FAST": True}}]) ref = APP / "출력" / "P1회귀_엔진수정" / "G5_fp.json" cmp = compare(json.loads(ref.read_text(encoding="utf-8")), json.loads(fp.read_text(encoding="utf-8"))) check("batch G5 model == individual-run G5 model (3190 objects, FAST fingerprints)", cmp["identical"], {k: cmp[k] for k in ("count", "objects_with_diffs", "diff_fields")}) check("planned FAIL gates still listed", len(p.gate_status("G3")["report"]["planned_fails"]) == 1 and len(p.gate_status("G4")["report"]["planned_fails"]) == 5) r = c.post(f"/pier/{P}/gates/run_all", data={"by": BY}, follow_redirects=True) check("nothing left to run", "이미 승인되어 있습니다" in html(r)) r = c.get(f"/pier/{P}/gates") check("gates page renders batch log", r.status_code == 200 and "일괄 승인 (필요하면 실행)" in html(r) and "G5 PASS" in html(r)) # an existing current PASS result is approved without running the gate again jid5 = p.gate_status("G5")["job"]["id"] c.post(f"/pier/{P}/gate/G5/decide", data={"by": BY, "decision": "반려", "note": "시험: 승인 취소"}) r = c.get(f"/pier/{P}") check("pier page offers 'G5 일괄 승인' for one unapproved gate", ">G5 일괄 승인" in html(r)) c.post(f"/pier/{P}/gates/run_all", data={"by": BY}) b2, secs2 = wait_batch(120) p = store.Pier(P) check("existing PASS approved without re-run", b2["state"] == "완료" and p.gate_status("G5")["approved"] and p.gate_status("G5")["job"]["id"] == jid5 and any("재실행 없음" in m for m in b2["log"]), f"{b2['log']} {secs2}s") out = APP / "검증" / "batch_web_test.json" out.write_text(json.dumps({"results": results, "passed": sum(x["result"] == "PASS" for x in results), "total": len(results)}, ensure_ascii=False, indent=1), encoding="utf-8") print("TOTAL", sum(x["result"] == "PASS" for x in results), "/", len(results)) if __name__ == "__main__": main()