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