"""P2 end-to-end test of the web app with the Flask test client (real ODA conversion and real Rhino runs). Uses its own data root (검증/web_test_data, recreated each run) with the Ungok P1 pier as template, and uploads copies of the Ungok P1 DWG files (RAG originals are only read). usage: python test_web.py [--gates G1..G5] (default: all five gates with approvals) """ import argparse import json import os import shutil import sys import time from io import BytesIO from pathlib import Path APP = Path(__file__).resolve().parents[1] ROOT = APP.parents[1] TD = APP / "검증" / "web_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 import store # noqa: E402 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)[:160], flush=True) def html(r): return r.get_data(as_text=True) def run_and_wait(c, P, g, limit=900): """POST run, then wait until the job with the id written at start-up is finished; returns (job, trace).""" c.post(f"/pier/{P}/gate/{g}/run") jid = (store.Pier(P).gate_status(g)["job"] or {}).get("id") t0, trace = time.time(), [] while time.time() - t0 < limit: job = store.Pier(P).gate_status(g)["job"] or {} trace.append((round(time.time() - t0), job.get("id") == jid, job.get("state"))) if jid and job.get("id") == jid and job.get("state") in ("완료", "오류"): return job, trace time.sleep(3) return None, trace def report_checks(c, P): r = c.post(f"/pier/{P}/report", data={"by": "테스트"}, follow_redirects=True) chk = json.loads((TD / P / "runs" / "current" / "report_check.json").read_text(encoding="utf-8")) md = next((TD / P / "runs" / "current").glob("보고서_*.md")).read_text(encoding="utf-8") check("report self-check ok (chapters, vocabulary, 37 schedule rows, 5 gate rows)", chk["ok"], chk) check("report: all gates PASS and approved", "전 게이트 PASS·승인" in md) check("report: 6 planned FAIL listed as 불일치", md.count("| 불일치 | G") == 6, md.count("| 불일치 | G")) check("report: B12 schedule 34 vs model 28 -> 불일치", "| B12 | III | S16 | 34 | 7800 | 28 | 불일치 |" in md) sec6 = md.split("## 6.")[1].split("## 7.")[0] check("report: other modelled marks match (35)", sec6.count("| 일치 |") == 35, sec6.count("| 일치 |")) check("report: UA1 outside model", "| UA1 | - | S16 | - | - | 모델 외 | - |" in md) rp = c.get(f"/pier/{P}/report/pdf") check("report PDF download", rp.status_code == 200 and rp.data[:4] == b"%PDF", len(rp.data)) rp.close() def main(gates): c = webapp.app.test_client() P = "테스트P1" # 1 pier creation: other types rejected, T형 accepted r = c.post("/pier/new", data={"name": "기타형", "type": "기타", "template": "운곡교P1"}, follow_redirects=True) check("non-T pier rejected", "대상 외 교각 형식" in html(r) and not (TD / "기타형").exists()) r = c.post("/pier/new", data={"name": P, "type": "T형", "template": "운곡교P1"}, follow_redirects=True) check("T pier created from template", r.status_code == 200 and (TD / P / "params.json").exists()) # 2 upload + convert + index (copies of the RAG DWG) dwgs = sorted((ROOT / "RAG").glob("C0050205-02*-운곡교 교각도*.dwg")) data = {"dwg": [(BytesIO(p.read_bytes()), p.name) for p in dwgs]} t0 = time.time() r = c.post(f"/pier/{P}/upload", data=data, content_type="multipart/form-data", follow_redirects=True) texts = store.Pier(P).texts() check("upload converts 3 DWG and indexes texts", len(dwgs) == 3 and len(texts) > 2000, f"{len(texts)} texts, {time.time() - t0:.0f}s") r = c.post(f"/pier/{P}/upload", data={"dwg": [(BytesIO(b"x"), "a.txt")]}, content_type="multipart/form-data", follow_redirects=True) check("non-DWG upload refused", "DWG 파일(.dwg)만" in html(r)) # 3 search r = c.get(f"/pier/{P}/search", query_string={"q": "EL. 26.208"}) check("search finds 'EL. 26.208'", "3건" in html(r), "") r = c.get(f"/pier/{P}/search", query_string={"q": "([", "regex": "1"}) check("bad regex reported, no crash", r.status_code == 200 and "검색식 오류" in html(r)) # 4 candidates: all equal on the template values; a changed value shows as different and can be re-applied r = c.get(f"/pier/{P}/candidates") # 2026-10-13 OPEN #19 A4: + 91 params rows (bearing_seats / sole_plates) -> 257 (was 166) check("candidates: 257 rows (166 + 91 params), 0 different", "후보값 비교 (257건 · 다름 0건)" in html(r)) E = store.Pier(P).load("expected") g5 = E["G5"] g5["el_x"]["1"] = 99999 r = c.post(f"/pier/{P}/section/expected/G5", data={"value": json.dumps(g5, ensure_ascii=False)}, follow_redirects=True) r = c.get(f"/pier/{P}/candidates") check("edited value detected as different", "다름 1건" in html(r)) from engine import reader pp = store.Pier(P) rows = reader.proposals(reader.field_candidates(reader.table_candidates(pp.texts())), pp.load("params"), pp.load("expected")) idx = [str(i) for i, x in enumerate(rows) if not x["same"]] r = c.post(f"/pier/{P}/candidates/apply", data={"row": idx}, follow_redirects=True) check("apply candidate restores drawing value", store.Pier(P).load("expected")["G5"]["el_x"]["1"] == 43625 and "다름 0건" in html(r)) r = c.post(f"/pier/{P}/section/expected/G5", data={"value": "[1, 2]"}, follow_redirects=True) check("type change refused", "형식이 바뀌었습니다" in html(r)) r = c.post(f"/pier/{P}/section/expected/G5", data={"value": "{bad json"}, follow_redirects=True) check("invalid JSON refused", "저장 안 됨" in html(r)) # 5 gate preconditions r = c.post(f"/pier/{P}/gate/G1/run", follow_redirects=True) check("G1 blocked while sections unconfirmed", "G1 실행 불가" in html(r) and "미확정" in html(r)) pier = store.Pier(P) need = {(x["file"], x["key"]) for g in store.GATE_SECTIONS for x in pier.gate_requirements(g)} for f, k in sorted(need): c.post(f"/pier/{P}/section/{f}/{k}/confirm", data={"by": "테스트", "why": "시험"}) # stage B: a bundle the app check does not report as 일치 needs a reason check("all required sections confirmed", all(x["confirmed"] for g in store.GATE_SECTIONS for x in store.Pier(P).gate_requirements(g)), len(need)) r = c.post(f"/pier/{P}/gate/G2/run", follow_redirects=True) check("G2 blocked before G1 approval", "G1 미승인" in html(r)) # 6 gates with approvals for g in gates: job, trace = run_and_wait(c, P, g) rep = store.Pier(P).gate_status(g)["report"] or {} check(f"{g} run via app -> PASS", bool(job) and job["state"] == "완료" and rep.get("verdict") == "PASS" and rep.get("job_id") == job["id"], f"{rep.get('summary')} {trace[-1][0]}s {(job or {}).get('error', '')[-200:]}") r = c.post(f"/pier/{P}/gate/{g}/decide", data={"by": "", "decision": "승인"}, follow_redirects=True) check(f"{g} approval needs a name", "판정자 이름" in html(r)) r = c.post(f"/pier/{P}/gate/{g}/decide", data={"by": "테스트", "decision": "승인"}, follow_redirects=True) check(f"{g} approved", store.Pier(P).gate_status(g)["approved"]) last = gates[-1] r = c.get(f"/pier/{P}/download/{last}") check(f"download {last}.3dm", r.status_code == 200 and len(r.data) > 100000, f"{len(r.data)} bytes") r.close() # 7a review report draft with all gates PASS and approved (only meaningful when all five gates ran) if len(gates) == 5: report_checks(c, P) # 7 an input change after approval makes gates stale and blocks the next run pr = store.Pier(P).load("params") pile = pr["piles"] pile["length"] += 5 # > checker tolerance 1.0 mm c.post(f"/pier/{P}/section/params/piles", data={"value": json.dumps(pile)}) st = store.Pier(P).gate_status("G1") check("input change -> gate stale and approval void", st["stale"] and not st["approved"]) check("changed section needs re-confirmation", store.Pier(P).section_state("params", "piles")["stale"]) # 7b a job left "실행 중" by a stopped process must not block the gate forever jp = TD / P / "runs" / "current" / "G1_job.json" store.wjson(jp, {"id": "orphan", "pid": -1, "state": "실행 중", "at": store.now(), "log": ["x"]}) r = c.get(f"/pier/{P}/gates") check("orphaned running job shown as 중단됨", "중단됨: 이전 실행" in html(r)) # 8 a wrong input gives a FAIL gate, which cannot be approved; rejection needs a reason c.post(f"/pier/{P}/section/params/piles/confirm", data={"by": "테스트", "why": "시험"}) # stage B: a bundle the app check does not report as 일치 needs a reason job, trace = run_and_wait(c, P, "G1") rep = store.Pier(P).gate_status("G1")["report"] check("pile length +5 mm -> G1 FAIL (pile length)", bool(job) and rep.get("job_id") == job["id"] and rep["verdict"] == "FAIL" and "pile length" in rep["unplanned_fails"], f"{rep['unplanned_fails']} trace {trace[:2]}..{trace[-2:]}") r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": "테스트", "decision": "승인"}, follow_redirects=True) check("FAIL gate cannot be approved", "승인할 수 없습니다" in html(r) and not store.Pier(P).gate_status("G1")["approved"]) r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": "테스트", "decision": "반려", "note": ""}, follow_redirects=True) check("rejection needs a reason", "반려 사유" in html(r)) r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": "테스트", "decision": "반려", "note": "말뚝 길이 입력 오류"}, follow_redirects=True) check("rejection recorded", store.Pier(P).approvals()["G1"]["decision"] == "반려") for page in ("/", f"/pier/{P}", f"/pier/{P}/gates", f"/pier/{P}/candidates", f"/pier/{P}/section/params/footing"): r = c.get(page) check(f"page renders {page}", r.status_code == 200) out = APP / "검증" / "P2_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__": ap = argparse.ArgumentParser() ap.add_argument("--gates", nargs="*", default=["G1", "G2", "G3", "G4", "G5"]) main(ap.parse_args().gates)