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:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
"""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)