- 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>
175 lines
12 KiB
Python
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)
|