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>
This commit is contained in:
commit
964a841de1
3978 files changed
+58013356
No files matched your search
@@ -0,0 +1,196 @@
|
||||
"""End-to-end web test of the Ulsan form (2026-10-13), as a user would work: new pier from the protected template
|
||||
산외교울산P1형, upload of a copy of RAG/교각배근도(울산P1).dwg, search and table candidates, confirmation, individual
|
||||
G1~G5 runs with approvals (save / reload / unchanged checks), report, model downloads, a wrong input caught by the
|
||||
checker, deletion. Own data root 검증/ulsan_e2e_data (recreated); real ODA conversion and Rhino runs.
|
||||
Results: 검증/ulsan_e2e_test.json
|
||||
"""
|
||||
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 / "검증" / "ulsan_e2e_data"
|
||||
if TD.exists():
|
||||
shutil.rmtree(TD)
|
||||
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
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from testlib import confirm_required # noqa: E402
|
||||
import store # noqa: E402
|
||||
|
||||
P, BY = "울산E2E", "시험자"
|
||||
EXP = {"G1": 104, "G2": 35, "G3": 63, "G4": 129, "G5": 75}
|
||||
results = []
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
results.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": str(detail)[:400]})
|
||||
print("PASS" if ok else "FAIL", name, str(detail)[:200], flush=True)
|
||||
|
||||
|
||||
def info(name, detail):
|
||||
results.append({"check": name, "result": "INFO", "detail": str(detail)[:2000]})
|
||||
print("INFO", name, str(detail)[:300], flush=True)
|
||||
|
||||
|
||||
def html(r):
|
||||
return r.get_data(as_text=True)
|
||||
|
||||
|
||||
def run_and_wait(c, g, limit=1500):
|
||||
c.post(f"/pier/{P}/gate/{g}/run")
|
||||
jid = (store.Pier(P).gate_status(g)["job"] or {}).get("id")
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < limit:
|
||||
job = store.Pier(P).gate_status(g)["job"] or {}
|
||||
if jid and job.get("id") == jid and job.get("state") in ("완료", "오류"):
|
||||
return job, round(time.time() - t0)
|
||||
time.sleep(5)
|
||||
return None, round(time.time() - t0)
|
||||
|
||||
|
||||
def main():
|
||||
c = webapp.app.test_client()
|
||||
tpl = TD / "_템플릿" / "산외교울산P1형"
|
||||
# 1 new pier from the protected template
|
||||
r = c.post("/pier/new", data={"name": P, "type": "T형", "template": "tpl:산외교울산P1형"}, follow_redirects=True)
|
||||
p = store.Pier(P)
|
||||
check("1 pier from protected template 산외교울산P1형", r.status_code == 200 and p.scope() == list(EXP)
|
||||
and p.info["template"] == "템플릿: 산외교울산P1형", p.info)
|
||||
# 2 upload + conversion + index
|
||||
dwg = ROOT / "RAG" / "교각배근도(울산P1).dwg"
|
||||
sha_before = store.hashlib.sha256(dwg.read_bytes()).hexdigest()
|
||||
t0 = time.time()
|
||||
r = c.post(f"/pier/{P}/upload", data={"dwg": [(BytesIO(dwg.read_bytes()), dwg.name)]},
|
||||
content_type="multipart/form-data", follow_redirects=True)
|
||||
texts = store.Pier(P).texts()
|
||||
audit = store.rjson(TD / P / "drawings" / "audit.json", [])
|
||||
check("2 upload converts the Ulsan DWG and indexes texts", len(audit) == 1 and len(texts) > 1000
|
||||
and "도면 1장 변환" in html(r), f"{len(texts)} texts, {time.time() - t0:.0f}s")
|
||||
check("2 RAG original unchanged (read only)", store.hashlib.sha256(dwg.read_bytes()).hexdigest() == sha_before)
|
||||
# 3 search: values the Ulsan reading used
|
||||
for q, why in (("EL. 42.763", "footing bottom"), ("28@250=7.000", "footing top chain"),
|
||||
("24@125=3.000", "coping top layers"), ("8@125", "seat reinforcement")):
|
||||
r = c.get(f"/pier/{P}/search", query_string={"q": q})
|
||||
n = sum(1 for t in texts if q in t["text"])
|
||||
check(f"3 search '{q}' ({why})", r.status_code == 200 and n > 0 and f"{n}건" in html(r), f"{n}")
|
||||
# 4 table candidates against the template values (same drawing -> expect no differences)
|
||||
r = c.get(f"/pier/{P}/candidates")
|
||||
h = html(r)
|
||||
import re
|
||||
m = re.search(r"후보값 비교 \((\d+)건 · 다름 (\d+)건\)", h)
|
||||
check("4 candidates page renders", r.status_code == 200)
|
||||
if m:
|
||||
check("4 candidates: template values equal the drawing tables (다름 0)", m.group(2) == "0",
|
||||
f"{m.group(1)} rows, {m.group(2)} different")
|
||||
else:
|
||||
info("4 candidates: no comparison table", re.sub(r"<[^>]+>", " ", h)[:600])
|
||||
from engine import reader
|
||||
# per-bearing value (G5 h3 is a dict per bearing in the Ulsan form): edit one bearing, detect, apply back
|
||||
E = store.Pier(P).load("expected")
|
||||
g5 = E["G5"]
|
||||
good = dict(g5["h3"])
|
||||
g5["h3"]["3"] = 999
|
||||
c.post(f"/pier/{P}/section/expected/G5", data={"value": json.dumps(g5, ensure_ascii=False)})
|
||||
pp = store.Pier(P)
|
||||
rows = reader.proposals(reader.field_candidates(reader.table_candidates(pp.texts())), pp.load("params"), pp.load("expected"))
|
||||
diff = [r for r in rows if not r["same"]]
|
||||
check("4 edited bearing-3 h3 detected as the only difference", len(diff) == 1 and diff[0]["path"] == ["G5", "h3", "3"],
|
||||
[(r["path"], r["current"], r["candidate"]) for r in diff])
|
||||
r = c.post(f"/pier/{P}/candidates/apply", data={"row": [str(rows.index(d)) for d in diff]}, follow_redirects=True)
|
||||
check("4 apply restores the drawing value and keeps the per-bearing form",
|
||||
store.Pier(P).load("expected")["G5"]["h3"] == good and "다름 0건" in html(r), store.Pier(P).load("expected")["G5"]["h3"])
|
||||
tabs = reader.table_candidates(texts)
|
||||
info("4 recognised tables", [{k: (v if not isinstance(v, (list, dict)) else len(v)) for k, v in t.items()} for t in tabs])
|
||||
# 5 confirmation: run blocked before, bulk confirm, then allowed
|
||||
r = c.post(f"/pier/{P}/gate/G1/run", follow_redirects=True)
|
||||
check("5 G1 blocked while sections unconfirmed", "G1 실행 불가" in html(r))
|
||||
need = webapp.unconfirmed(store.Pier(P))
|
||||
confirm_required(c, webapp, store, P, BY)
|
||||
check("5 bulk confirm (all required sections)", not webapp.unconfirmed(store.Pier(P)), f"{len(need)} sections")
|
||||
# 6 individual runs + approvals
|
||||
for g, n in EXP.items():
|
||||
job, sec = run_and_wait(c, g)
|
||||
rep = store.Pier(P).gate_status(g)["report"] or {}
|
||||
s = rep.get("summary", {})
|
||||
check(f"6 {g} individual run -> PASS {n}/{n}, reload and unchanged verified",
|
||||
bool(job) and job["state"] == "완료" and rep.get("verdict") == "PASS" and s.get("passed") == n
|
||||
and rep.get("mode") != "batch" and (rep.get("reload") or {}).get("ok") is True
|
||||
and (rep.get("unchanged") or {}).get("ok") in (True, None),
|
||||
f"{s.get('passed')}/{s.get('total')} {sec}s reload={(rep.get('reload') or {}).get('ok')} "
|
||||
f"unchanged={(rep.get('unchanged') or {}).get('ok')} {(job or {}).get('error', '')[-200:]}")
|
||||
c.post(f"/pier/{P}/gate/{g}/decide", data={"by": BY, "decision": "승인"})
|
||||
check(f"6 {g} approved", store.Pier(P).gate_status(g)["approved"])
|
||||
# 7 downloads (individual runs save every gate model)
|
||||
for g in EXP:
|
||||
r = c.get(f"/pier/{P}/download/{g}")
|
||||
check(f"7 download {g}.3dm", r.status_code == 200 and len(r.data) > 100000, f"{len(r.data)} bytes")
|
||||
r.close()
|
||||
# 8 report
|
||||
r = c.post(f"/pier/{P}/report", data={"by": BY}, follow_redirects=True)
|
||||
rc = store.rjson(TD / P / "runs" / "current" / "report_check.json", {})
|
||||
md_files = sorted((TD / P / "runs" / "current").glob("보고서_*.md"))
|
||||
md = md_files[-1].read_text(encoding="utf-8") if md_files else ""
|
||||
check("8 report generated, all gates PASS and approved", bool(md) and "게이트 진행: 5/5 실행" in md
|
||||
and "전 게이트 PASS·승인" in md, rc)
|
||||
sec6 = md.split("## 6.")[1].split("## 7.")[0] if "## 6." in md and "## 7." in md else ""
|
||||
rows6 = [l for l in sec6.splitlines() if l.startswith("| ") and not l.startswith("| :") and "마크" not in l]
|
||||
check("8 report ch.6: 34 schedule marks, all 일치 with the model", len(rows6) == 34 and all("| 일치 |" in l for l in rows6),
|
||||
f"{len(rows6)} rows; not 일치: {[l for l in rows6 if '| 일치 |' not in l][:3]}")
|
||||
check("8 report self-check passed", rc.get("ok") is True, rc)
|
||||
rp = c.get(f"/pier/{P}/report/pdf")
|
||||
check("8 report PDF download", rp.status_code == 200 and rp.data[:4] == b"%PDF", len(rp.data))
|
||||
rp.close()
|
||||
# 9 wrong input caught: footing width 7,500 -> 7,505 in expected G1. Any input change voids every approval
|
||||
# (inputs hash), so G3 is blocked until G1 is approved again; G1 runs FAIL and cannot be approved; restore
|
||||
E = store.Pier(P).load("expected")
|
||||
g1 = E["G1"]
|
||||
g1["footing"]["dx"] = 7505
|
||||
c.post(f"/pier/{P}/section/expected/G1", data={"value": json.dumps(g1, ensure_ascii=False)})
|
||||
pp = store.Pier(P)
|
||||
check("9 input change -> all gates stale, approvals void", all(pp.gate_status(g)["stale"] and not pp.gate_status(g)["approved"]
|
||||
for g in EXP))
|
||||
c.post(f"/pier/{P}/section/expected/G1/confirm", data={"by": BY, "why": "시험"}) # stage B: reason for a non-일치 bundle
|
||||
r = c.post(f"/pier/{P}/gate/G3/run", follow_redirects=True)
|
||||
check("9 G3 blocked until G1 is approved again", "G3 실행 불가" in html(r) and "미승인" in html(r))
|
||||
job, sec = run_and_wait(c, "G1")
|
||||
rep = store.Pier(P).gate_status("G1")["report"] or {}
|
||||
check("9 footing dx 7,505 -> G1 FAIL (footing checks)", bool(job) and rep.get("job_id") == job["id"]
|
||||
and rep.get("verdict") == "FAIL" and any("footing" in f for f in rep.get("unplanned_fails", [])),
|
||||
rep.get("unplanned_fails"))
|
||||
r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": BY, "decision": "승인"}, follow_redirects=True)
|
||||
check("9 FAIL gate cannot be approved", "승인할 수 없습니다" in html(r) and not store.Pier(P).gate_status("G1")["approved"])
|
||||
g1["footing"]["dx"] = 7500
|
||||
c.post(f"/pier/{P}/section/expected/G1", data={"value": json.dumps(g1, ensure_ascii=False)})
|
||||
c.post(f"/pier/{P}/section/expected/G1/confirm", data={"by": BY, "why": "시험"}) # stage B: reason for a non-일치 bundle
|
||||
job, sec = run_and_wait(c, "G1")
|
||||
c.post(f"/pier/{P}/gate/G1/decide", data={"by": BY, "decision": "승인"})
|
||||
pp = store.Pier(P)
|
||||
check("9 restored input -> G1 PASS and approved; G2~G5 approvals valid again (same inputs as approved)",
|
||||
all(pp.gate_status(g)["approved"] for g in EXP), {g: pp.gate_status(g)["approved"] for g in EXP})
|
||||
# 10 pages, deletion, template intact
|
||||
for page in ("/", f"/pier/{P}", f"/pier/{P}/gates", f"/pier/{P}/candidates", f"/pier/{P}/search?q=C7",
|
||||
f"/pier/{P}/section/params/coping_rebar"):
|
||||
check(f"10 page renders {page}", c.get(page).status_code == 200)
|
||||
tsha = [(tpl / f).read_bytes() for f in ("params.json", "expected.json")]
|
||||
c.post(f"/pier/{P}/delete", data={"confirm_name": P})
|
||||
check("10 delete moves the pier to the trash; template unchanged", not (TD / P).exists()
|
||||
and any((TD / "_휴지통").glob(f"{P}_*")) and tsha == [(tpl / f).read_bytes() for f in ("params.json", "expected.json")])
|
||||
out = APP / "검증" / "ulsan_e2e_test.json"
|
||||
n_ok = sum(x["result"] == "PASS" for x in results)
|
||||
n = sum(x["result"] != "INFO" for x in results)
|
||||
out.write_text(json.dumps({"results": results, "passed": n_ok, "total": n}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print("TOTAL", n_ok, "/", n)
|
||||
return 0 if n_ok == n else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user