"""Stage B test of the 입력 묶음 확인·확정 개편 (2026-10-16), no Rhino: the app check (engine/verify.py), the evidence page, the reason rule for bundles that are not 일치, the D4 bulk confirmation, and the report chapter. Own data root 검증/autocheck_test_data (recreated); real piers are not touched. Acceptance (기획서 6장): no false 일치 / no false 불일치 on the real piers, a changed value is caught, the app never writes a confirmation, nothing is hidden. """ import json import os import shutil import sys from pathlib import Path APP = Path(__file__).resolve().parents[1] TD = APP / "검증" / "autocheck_test_data" if TD.exists(): shutil.rmtree(TD) def _src(n): """data/, else the newest recycle-bin copy (a pier may be deleted in real use: 운곡교P3 on 2026-10-20)""" d = APP / "data" / n if d.exists(): return d return sorted((APP / "data" / "_휴지통").glob(f"{n}_*"))[-1] for n in ("새재1교P1", "산외교울산P2", "옥성교P1", "운곡교P1", "운곡교P3", "Ramp-A교P1"): shutil.copytree(_src(n), TD / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf", "confirm.json", "autocheck.json", "confirm_view.json", "edit_log.json")) # 운곡교P3 holds the finished (gate-verified) values since 2026-10-19; this test wants the WRONG-input state (values of another pier, P3 drawing) -> the first backup _bk = sorted((_src("운곡교P3") / "backup").glob("before_anchor_A_*")) if _bk: for _f in ("params.json", "expected.json"): shutil.copy2(_bk[0] / _f, TD / "운곡교P3" / _f) shutil.copytree(APP / "data" / "_템플릿", 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 from engine import verify # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent)) from testlib import confirm_required # noqa: E402 ok = True def check(name, cond, detail=""): global ok ok &= bool(cond) print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True) def st_of(p, b, force=True): a = p.run_autocheck(force=force) return a["sections"][b]["st"] # ---- 1. real piers: no false 불일치 (every bundle that is not a known data problem), P3 (an unmodelled copy of P1) is caught for n in ("새재1교P1", "산외교울산P2", "옥성교P1", "운곡교P1", "Ramp-A교P1"): p = store.Pier(n) a = p.run_autocheck(force=True) bad = [b for b, r in a["sections"].items() if r["st"] == "b"] check(f"{n}: no 불일치 on its own data", not bad, bad) req = {f"{f}:{k}" for f, k in webapp.required(p)} miss = [b for b in req if b not in a["sections"]] check(f"{n}: every required bundle has an app-check status", not miss, miss) check(f"{n}: app check wrote no confirmation", not store.rjson(TD / n / "confirm.json", {})) p3 = store.Pier("운곡교P3") a3 = p3.run_autocheck(force=True) bad3 = sorted(b for b, r in a3["sections"].items() if r["st"] == "b") check("운곡교P3 (values of another pier, P3 drawing): the table bundles are 불일치", {"expected:G1", "expected:G5", "params:bearing_seats", "params:sole_plates"} <= set(bad3), bad3) # ---- 2. a changed value is caught (no false 일치): 새재1교P1 p = store.Pier("새재1교P1") base = p.run_autocheck(force=True)["sections"] check("새재1교P1: bearing_seats / sole_plates / expected.G5 start as 일치", all(base[b]["st"] == "g" for b in ("params:bearing_seats", "params:sole_plates", "expected:G5")), {b: base[b]["st"] for b in ("params:bearing_seats", "params:sole_plates", "expected:G5")}) P0 = p.load("params") P0["bearing_seats"]["bearings"][0]["h1"] += 5 p.save("params", P0) check("changed bearing h1 -> params:bearing_seats 불일치", st_of(p, "params:bearing_seats") == "b") P0["bearing_seats"]["bearings"][0]["h1"] -= 5 p.save("params", P0) check("restored -> 일치 again", st_of(p, "params:bearing_seats") == "g") E0 = p.load("expected") E0["G5"]["el_x"]["1"] += 1 p.save("expected", E0) check("changed expected.G5.el_x -> 불일치", st_of(p, "expected:G5") == "b") E0["G5"]["el_x"]["1"] -= 1 p.save("expected", E0) P0 = p.load("params") P0["coping_rebar"]["bars"] = [b for b in P0["coping_rebar"]["bars"] if not (b["mark"] == "C7" and b == next(x for x in P0["coping_rebar"]["bars"] if x["mark"] == "C7"))] p.save("params", P0) r = p.run_autocheck(force=True)["sections"]["params:coping_rebar"] check("one coping bar C7 removed -> coping_rebar 불일치 (C4 count)", r["st"] == "b" and any(c["id"] == "C4" and c["st"] == "b" for c in r["checks"]), r["summary"]) # the count-only bundle is never 일치 (no false 일치): a polylines bundle with all counts right is 주의 p2 = store.Pier("옥성교P1") r2 = p2.run_autocheck(force=True)["sections"] check("옥성교P1: coping_rebar / footing_rebar (counts only) are 주의, not 일치", r2["params:coping_rebar"]["st"] == "w" and r2["params:footing_rebar"]["st"] == "w", (r2["params:coping_rebar"]["st"], r2["params:footing_rebar"]["st"])) # a range error P0 = p.load("params") P0["footing"]["thickness"] = -5 p.save("params", P0) check("negative thickness -> footing 불일치 (C6)", st_of(p, "params:footing") == "b") # ---- 3. new pier from a template: original values are 근거 없음 (C3), and a bulk confirmation does not touch them c = webapp.app.test_client() c.post("/pier/new", data={"name": "신규시험", "type": "T형", "template": "tpl:새재1교P1형"}) q = store.Pier("신규시험") aq = q.run_autocheck(force=True)["sections"] req = webapp.required(q) orig = [f"{f}:{k}" for f, k in req if q.section_meta(f, k)["origin"] and q.section_meta(f, k)["grade"] != "F"] # F bundles are judged by C7 (same as the template = 일치) check("new pier: every required non-F bundle that is still the template original is not 일치 unless a table supports it", all(aq[b]["st"] != "g" or any(x["id"] == "C2" and x["st"] == "g" for x in aq[b]["checks"]) for b in orig), [b for b in orig if aq[b]["st"] == "g"]) check("new pier: original bundles without table support are 근거 없음 (C3)", all(aq[b]["st"] in ("n", "x") or any(x["id"] == "C2" and x["st"] == "g" for x in aq[b]["checks"]) for b in orig), {b: aq[b]["st"] for b in orig if aq[b]["st"] not in ("n", "x")}) # the evidence page r = c.get("/pier/신규시험/evidence/params/footing") html = r.get_data(as_text=True) check("evidence page renders with the 8 checks and the status", r.status_code == 200 and all(f"C{i}" in html for i in range(1, 9)) and "근거 없음" in html) check("pier page shows the summary strip, the app-check column and the Prism block", "앱 확인 일치" in c.get("/pier/신규시험").get_data(as_text=True) and 'class="prism"' in c.get("/pier/신규시험").get_data(as_text=True)) # ---- 4. a bundle that is not 일치 needs a reason; with it the record keeps reason and status r = c.post("/pier/신규시험/section/params/footing/confirm", data={"by": "시험자"}, follow_redirects=True) check("confirm of a 근거 없음 bundle without reason refused", "사유를 입력" in r.get_data(as_text=True) and not q.section_state("params", "footing")["confirmed"]) c.post("/pier/신규시험/section/params/footing/confirm", data={"by": "시험자", "why": "도면 정면도 7.700과 직접 대조"}) s = q.section_state("params", "footing") check("with reason: confirmed, reason and app status recorded", s["confirmed"] and s["why"] == "도면 정면도 7.700과 직접 대조" and s["app_st"] == "n", s) # ---- 5. D4: 일치 묶음 일괄 확정 only touches 일치 bundles, needs the evidence table read, a name, and is refused when the list changed p = store.Pier("새재1교P1") p.save("params", json.loads(json.dumps(p.load("params")))) # keep file; reset the damage below src = json.loads((APP / "data" / "새재1교P1" / "params.json").read_text(encoding="utf-8")) p.save("params", src) p.save("expected", json.loads((APP / "data" / "새재1교P1" / "expected.json").read_text(encoding="utf-8"))) tg = webapp.matched_targets(p) check("matched targets are exactly the 일치 / unconfirmed required bundles", tg and all(p.section_check(f, k)["st"] == "g" for f, k in tg), len(tg)) r = c.post("/pier/새재1교P1/confirm_matched", data={"by": "시험자"}, follow_redirects=True) check("bulk confirm refused before the evidence table was read", "끝까지 읽은 뒤" in r.get_data(as_text=True) and not any(p.section_state(f, k)["confirmed"] for f, k in tg)) r = c.get("/pier/새재1교P1/confirm_matched") check("evidence table page lists the targets with their basis", r.status_code == 200 and all(f"{f}.{k}" in r.get_data(as_text=True) for f, k in tg[:3])) check("opening the page alone does not mark it as read", not store.rjson(TD / "새재1교P1" / "confirm_view.json", {})) c.post("/pier/새재1교P1/confirm_matched/read") r = c.post("/pier/새재1교P1/confirm_matched", data={"by": ""}, follow_redirects=True) check("bulk confirm needs a name", "확정자 이름" in r.get_data(as_text=True) and not any(p.section_state(f, k)["confirmed"] for f, k in tg)) r = c.post("/pier/새재1교P1/confirm_matched", data={"by": "시험자"}, follow_redirects=True) conf = {f"{f}:{k}" for f, k in tg if p.section_state(f, k)["confirmed"]} others = [s for s in p.sections() if not p.section_state(s["file"], s["key"])["confirmed"] and (s["file"], s["key"]) in webapp.required(p)] check("bulk confirm confirmed exactly the 일치 bundles", conf == {f"{f}:{k}" for f, k in tg} and all(p.section_check(s["file"], s["key"])["st"] != "g" for s in others), (len(conf), len(others))) check("records keep the name and app status g", all(p.confirmations()[b].get("app_st") == "g" and p.confirmations()[b]["by"] == "시험자" for b in conf)) # the marker is bound to the target list: after a change the old marker is useless P0 = p.load("params") P0["bearing_seats"]["bearings"][1]["h1"] += 3 p.save("params", P0) c.post("/pier/새재1교P1/confirm_matched/read") P0["bearing_seats"]["bearings"][1]["h1"] -= 3 p.save("params", P0) q2 = store.Pier("새재1교P1") for f, k in tg[:2]: # re-open two targets by editing nothing: remove their confirmation cf = q2.confirmations(); cf.pop(f"{f}:{k}", None); store.wjson(TD / "새재1교P1" / "confirm.json", cf) r = c.post("/pier/새재1교P1/confirm_matched", data={"by": "시험자"}, follow_redirects=True) check("a stale marker (target list changed since it was read) is refused", "끝까지 읽은 뒤" in r.get_data(as_text=True)) # ---- 6. the legacy bulk route still works (stage D decides) and the report carries chapter 8.1 r = c.post("/pier/신규시험/confirm_all", data={"by": "시험자"}) check("the old 필요한 묶음 일괄 확정 route is gone (2026-10-19)", r.status_code in (404, 405), r.status_code) confirm_required(c, webapp, store, "신규시험", "시험자") check("every required bundle can still be confirmed one by one with a reason", not webapp.unconfirmed(store.Pier("신규시험"))) from engine import report # noqa: E402 md, d = report.build(TD / "신규시험", TD / "신규시험" / "runs" / "current", "시험자", "2026-10-16") check("report chapter 8.1 lists the app-check summary and the human confirmation", "### 8.1 입력 묶음 앱 확인 결과 요약" in md and "근거 없음" in md and "시험자" in md) print("RESULT", "PASS" if ok else "FAIL") sys.exit(0 if ok else 1)