Files
Pier_v1/Code/교각모델링앱/tools/test_autocheck.py
T
ahnyongjooandClaude Sonnet 5.5 964a841de1 chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- 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>
2026-10-28 11:20:18 +09:00

175 lines
12 KiB
Python

"""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/<n>, 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)