Files
Pier_v1/Code/교각모델링앱/tools/test_anchors.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

228 lines
16 KiB
Python

"""Stage C (part 1) test of the 입력 묶음 확인·확정 개편 (2026-10-19), no Rhino: anchors (engine/anchors.py) learned from one modelled pier and followed
into the drawing of another, the web screen, the apply rules and the effect on the app check. Own data root 검증/anchors_test_data (recreated).
Acceptance (기획서 4.2 / 6장): grade A has no wrong candidate in the same series, a drawing frame that differs gets no A / B candidate (only C), a
candidate is never invented where nothing is found, the R2 chain check finds the known drawing error, and the app never applies a candidate by itself.
"""
import itertools
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "anchors_test_data"
if TD.exists():
shutil.rmtree(TD)
D = APP / "data"
names = ["산외교울산P1", "산외교울산P2", "운곡교P1", "운곡교p2", "운곡교P3", "운곡교P4", "새재1교P1", "옥성교P1", "Ramp-A교P1"]
SERIES = {"산외교울산P1": "U", "산외교울산P2": "U", "운곡교P1": "G", "운곡교p2": "G", "운곡교P3": "G", "운곡교P4": "G", "새재1교P1": "S", "옥성교P1": "O", "Ramp-A교P1": "R"}
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "web"))
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD) # BEFORE the first import of store / app (store reads DATA at import time)
import app as webapp # noqa: E402
import store # noqa: E402
from engine import anchors # 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 src(n):
"""data/<n>, or the newest copy in data/_휴지통 when the pier was deleted in real use (운곡교p2 was, 2026-10-19); read only."""
d = D / n
if (d / "params.json").exists():
return d
trash = sorted(t for t in (D / "_휴지통").glob(f"{n}_*") if t.name.startswith(n + "_")) # exact case: Windows globbing is case-insensitive (운곡교p2 / 운곡교P2)
return trash[-1] if trash else d
def load(n):
return [json.loads((src(n) / f).read_text(encoding="utf-8")) for f in ("params.json", "expected.json", "drawings/texts.json")]
def get(docs, field):
return store.get_field(docs, field)
L = {n: load(n) for n in names}
# ---- 1. same series: grade A has no wrong candidate; B is mostly right; C is weak and labelled so
st = {g: [0, 0] for g in "ABC"}
recovered = 0
for a, b in [("산외교울산P1", "산외교울산P2"), ("산외교울산P2", "산외교울산P1"), ("운곡교P1", "운곡교p2"), ("운곡교p2", "운곡교P1")]:
Pa, Ea, Ta = L[a]
Pb, Eb, Tb = L[b]
anc = anchors.learn(Pa, Ea, Ta)
cand = anchors.follow(anc, Tb)
for f, c in cand.items():
tv = get({"params": Pb, "expected": Eb}, f)
if tv is None:
continue
good = abs(abs(tv) - abs(c["value"])) < 1.5
st[c["grade"]][0] += 1
st[c["grade"]][1] += good
recovered += c["grade"] == "A" and good and abs(abs(c["source_value"]) - abs(tv)) > 1.5
check(f"{a} -> {b}: frame coverage >= 70 %", next(iter(cand.values()))["coverage"] >= 0.7, next(iter(cand.values()))["coverage"])
check(f"same series: grade A all right ({st['A'][1]}/{st['A'][0]}), at least 30 A candidates", st["A"][0] >= 30 and st["A"][1] == st["A"][0], st["A"])
check(f"same series: changed values recovered by A candidates ({recovered}) >= 10", recovered >= 10)
check(f"same series: grade B right in >= 80 % ({st['B'][1]}/{st['B'][0]})", st["B"][0] and st["B"][1] / st["B"][0] >= 0.8, st["B"])
print("INFO grade C right", st["C"], "(shown to the user as weak, needs a reason to apply)")
# ---- 1b. table cross-check: a field with a table value is confirmed (A) or corrected (C) by it
import copy # noqa: E402
from engine import reader # noqa: E402
st2 = {g: [0, 0] for g in "ABC"}
n_conf = n_low = 0
for a, b in [("산외교울산P1", "산외교울산P2"), ("산외교울산P2", "산외교울산P1"), ("운곡교P1", "운곡교p2"), ("운곡교p2", "운곡교P1")]:
Pa, Ea, Ta = L[a]
Pb, Eb, Tb = L[b]
cand = anchors.follow(anchors.learn(Pa, Ea, Ta), Tb)
tab = anchors.table_values(reader.proposals(reader.field_candidates(reader.table_candidates(Tb)), Pb, Eb))
after = anchors.cross_check(copy.deepcopy(cand), tab)
n_conf += sum(1 for c in after.values() if c.get("table_confirmed"))
n_low += sum(1 for c in after.values() if c.get("table_confirmed") is False)
for f, c in after.items():
tv = get({"params": Pb, "expected": Eb}, f)
if tv is None:
continue
st2[c["grade"]][0] += 1
st2[c["grade"]][1] += abs(abs(tv) - abs(c["value"])) < 1.5
check(f"table cross-check: grade A is still all right ({st2['A'][1]}/{st2['A'][0]}) and larger than before ({st['A'][0]})", st2["A"][0] > st["A"][0] and st2["A"][1] == st2["A"][0], st2["A"])
check(f"table cross-check: {n_conf} candidates confirmed by a table value, {n_low} lowered to C by a different one", n_conf >= 100)
# ---- 2. a different drawing frame: no A / B candidate, nothing invented
bad_frames = []
for a, b in itertools.permutations(names, 2):
if SERIES[a] == SERIES[b]:
continue
anc = anchors.learn(*L[a][:2], L[a][2])
cand = anchors.follow(anc, L[b][2])
n_ab = sum(1 for c in cand.values() if c["grade"] in ("A", "B"))
if n_ab:
bad_frames.append((a, b, n_ab))
check("different series (frame coverage < 70 %): no A or B candidate in any of the other-series pairs", not bad_frames, bad_frames[:3])
anc = anchors.learn(*L["Ramp-A교P1"][:2], L["Ramp-A교P1"][2])
check("a drawing with none of the anchors' texts gives no candidate at all", not anchors.follow(anc, L["운곡교P1"][2]) or all(c["grade"] == "C" for c in anchors.follow(anc, L["운곡교P1"][2]).values()))
assert Path(str(store.DATA)) == TD, store.DATA # the test never touches data/
check("anchors skip values below 1 m and fields of F / V3 bundles", all(abs(a["v"]) >= 1000 for a in anchors.learn(*L["옥성교P1"][:2], L["옥성교P1"][2])["fields"].values())
and not any(f.startswith(("params:coping_rebar", "params:footing_rebar", "params:scope")) for f in anchors.learn(*L["옥성교P1"][:2], L["옥성교P1"][2])["fields"]))
# ---- 3. R2 chain check: the known error of the Ungok P4 drawing (62@125=7.250 and 29@250=7.750 are swapped totals), no false alarm elsewhere
bad = {n: [c["text"] for c in anchors.chains(L[n][2]) if not c["ok"]] for n in names}
check("R2: Ungok P4 drawing has the two inconsistent chains", sorted(bad["운곡교P4"]) == sorted(["62@125=7.250", "29@250=7.750"]), bad["운곡교P4"])
check("R2: no inconsistent chain in the other 8 drawings", all(not v for n, v in bad.items() if n != "운곡교P4"), {n: v for n, v in bad.items() if v and n != "운곡교P4"})
# ---- 4. web flow: a new Ungok pier (template 운곡교P1형) with the drawing index of Ungok P2 (a different pier of the same series)
for n in ("운곡교p2",):
shutil.copytree(src(n), TD / "_src" / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "confirm.json"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
c = webapp.app.test_client()
c.post("/pier/new", data={"name": "앵커시험", "type": "T형", "template": "tpl:운곡교P1형"})
p = store.Pier("앵커시험")
check("new pier carries the template's anchors", (p.dir / "anchors.json").exists() and not p.texts())
rows, miss, meta = p.anchor_rows()
check("no drawing yet -> no candidates (nothing invented)", rows == [] and meta["texts"] is False)
shutil.copytree(TD / "_src" / "운곡교p2" / "drawings", p.drawings, dirs_exist_ok=True) # "upload": index + converted DXF + audit of Ungok P2
aud = store.rjson(p.drawings / "audit.json", [])
for a in aud:
a["source_sha256"] = "x" + a.get("source_sha256", "")[1:] # a different file than the template's source drawing
store.wjson(p.drawings / "audit.json", aud)
rows, miss, meta = p.anchor_rows()
tr = store.Pier("운곡교p2") if False else None
P2 = json.loads((src("운곡교p2") / "params.json").read_text(encoding="utf-8"))
E2 = json.loads((src("운곡교p2") / "expected.json").read_text(encoding="utf-8"))
diff_a = [r for r in rows if r["grade"] == "A" and not r["same"]]
check(f"new drawing: coverage >= 70 %, {len(rows)} candidates, {len(diff_a)} A candidates differ from the template's values", meta["coverage"] >= 0.7 and diff_a, meta)
check("every differing A candidate equals the value of the modelled pier 운곡교p2 (no wrong A)", all(abs(abs(store.get_field({"params": P2, "expected": E2}, r["field"]) or 0) - abs(r["value"])) < 1.5
for r in diff_a), [r["field"] for r in diff_a if abs(abs(store.get_field({"params": P2, "expected": E2}, r["field"]) or 0) - abs(r["value"])) >= 1.5])
html = c.get("/pier/앵커시험/candidates").get_data(as_text=True)
check("candidates page lists the out-of-table candidates with grades, positions and the view link", "표 밖 치수 후보" in html and "등급 A" in html and "도면 보기" in html and "도면 틀 일치율" in html)
check("candidate page offers the one-file quick selection of grade A rows and shows the table confirmation", 'data-sel-a="expected"' in html and 'data-sel-a="params"' in html and "표 값과도 일치" in html)
check("the app applied nothing by itself", p.load("params") == json.loads((D / "_템플릿" / "운곡교P1형" / "params.json").read_text(encoding="utf-8")))
# ---- 5. app check: the A candidates that differ make the bundle 불일치; applying them makes it agree
a0 = p.run_autocheck(force=True)["sections"]
bundles = sorted({r["field"].rsplit(".", 0)[0].split(".")[0] for r in diff_a})
hit = [b for b in bundles if a0[b]["st"] == "b" and any(x["id"] == "C2b" and x["st"] == "b" for x in a0[b]["checks"])]
check("bundles with a differing A candidate are 불일치 by C2b", set(hit) == set(bundles) and hit, {b: a0[b]["st"] for b in bundles})
# ---- 6. apply rules
pa = [r for r in diff_a if r["file"] == "params"]
ex = [r for r in diff_a if r["file"] == "expected"]
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": []}, follow_redirects=True)
check("applying nothing is refused", "반영할 후보를 고르세요" in r0.get_data(as_text=True))
same_rows = [r for r in rows if r["same"]]
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [same_rows[0]["field"]]}, follow_redirects=True)
check("a candidate equal to the current value is not applied", "반영할 후보를 고르세요" in r0.get_data(as_text=True))
twin = next(((a, b) for a in pa for b in ex if (a["text"], a["x"], a["y"], a["value"]) == (b["text"], b["x"], b["y"], b["value"])), None)
if twin:
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [twin[0]["field"], twin[1]["field"]]}, follow_redirects=True)
check("the same drawing text into params and expected together is refused (P7)", "함께 반영할 수 없습니다" in r0.get_data(as_text=True))
weak = next((r for r in rows if r["grade"] in ("B", "C") and not r["same"] and r["grade"] == "C"), None)
if weak:
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [weak["field"]]}, follow_redirects=True)
check("a grade C candidate needs a reason", "사유를 입력" in r0.get_data(as_text=True))
first = pa[0]
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [first["field"]]}, follow_redirects=True)
check("applying a differing A candidate writes the value and a log entry", abs(store.get_field({"params": p.load("params"), "expected": p.load("expected")}, first["field"]) - first["value"]) < 1e-6
and store.rjson(p.dir / "anchor_log.json", [])[-1]["fields"][0]["field"] == first["field"])
twin_b = next((b for b in ex if (b["text"], b["x"], b["y"]) == (first["text"], first["x"], first["y"])), None)
if twin_b:
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [twin_b["field"]]}, follow_redirects=True)
check("the same text may not be applied to expected later either (P7 across requests)", "함께 반영할 수 없습니다" in r0.get_data(as_text=True))
check("the applied bundle needs a new confirmation", not p.section_state(first["field"].split(":")[0], first["field"].split(":")[1].split(".")[0])["confirmed"])
# ---- 7. a pier without anchors can load them from a template; own-drawing anchors are no evidence
q = store.Pier("앵커시험")
c.post("/pier/new", data={"name": "복사시험", "type": "T형", "template": "tpl:산외교울산P2형"})
q2 = store.Pier("복사시험")
os.remove(q2.dir / "anchors.json")
r0 = c.post("/pier/복사시험/anchors/load", data={"template": "산외교울산P1형"}, follow_redirects=True)
check("anchors can be loaded from a template into a pier without anchors", (q2.dir / "anchors.json").exists() and "앵커를 불러왔습니다" in r0.get_data(as_text=True))
shutil.copytree(D / "산외교울산P2" / "drawings", q2.drawings, dirs_exist_ok=True, ignore=shutil.ignore_patterns("dwg", "dxf"))
os.remove(q2.dir / "anchors.json")
shutil.copy2(D / "_템플릿" / "산외교울산P2형" / "anchors.json", q2.dir / "anchors.json") # anchors learned from this very drawing
aud = store.rjson(q2.drawings / "audit.json", [])
srcs = {a["source_sha256"] for a in aud}
anc2 = store.rjson(q2.dir / "anchors.json")
check("template anchors remember the source drawing", set(anc2["sources"]) == srcs, anc2["sources"][:1])
r2, _m, meta2 = q2.anchor_rows()
check("own-drawing anchors are flagged and give no evidence to the app check", meta2["self_source"] and not any(
x["id"] == "C2b" for r in q2.run_autocheck(force=True)["sections"].values() for x in r["checks"]))
# ---- 8. the region image
ent = aud[0]
shutil.copytree(D / "산외교울산P2" / "drawings" / "dxf", q2.drawings / "dxf", dirs_exist_ok=True)
r0 = c.get(f"/pier/복사시험/view/{ent['label']}?x=-85062&y=-14500&half=3000")
check("region image route returns a PNG", r0.status_code == 200 and r0.data[:4] == b"\x89PNG" and len(r0.data) > 5000, (r0.status_code, len(r0.data)))
check("bad view arguments are refused", c.get("/pier/복사시험/view/D1?x=abc&y=1").status_code == 400 and c.get("/pier/복사시험/view/D9?x=1&y=1").status_code == 404)
# ---- 9. pier page notice for the drawing's own chain errors
shutil.copytree(D / "운곡교P4", TD / "운곡교P4", ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf"))
h4 = c.get("/pier/운곡교P4").get_data(as_text=True)
check("pier page warns about the inconsistent drawing chains", "치수 사슬 2건이 검산에 맞지 않습니다" in h4 and "62@125=7.250" in h4)
# ---- 10. improvement #34-4 (2026-10-20): a candidate whose only followed instance is ONE schedule cell is demoted to C (Ungok P3 / P4: the S1 length cell
# '3.800' was followed as the end of a rebar chain and graded B)
anc1 = anchors.learn(*L["운곡교P1"][:2], L["운곡교P1"][2])
for tgt in ("운곡교P3", "운곡교P4"):
cand = anchors.follow(anc1, L[tgt][2])
lone = {f: c for f, c in cand.items() if c["followed"] == 1 and "TABL" in c["layer"]}
check(f"{tgt}: {len(lone)} candidates rest on one table cell and none is graded A or B", lone and all(c["grade"] == "C" and c["table_only"] for c in lone.values()),
[(f, c["grade"]) for f, c in lone.items() if c["grade"] != "C"][:3])
check(f"{tgt}: the false chain-end candidate G3.rows.X_250.ends is not offered above C",
all(cand[f]["grade"] == "C" for f in cand if f.startswith("expected:G3.rows.X_250.ends")))
others = [c for c in cand.values() if "TABL" not in c["layer"] or c["followed"] > 1]
check(f"{tgt}: candidates with several instances or off-table instances keep their grade (A {sum(c['grade'] == 'A' for c in others)})", any(c["grade"] == "A" for c in others))
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)