"""OPEN #19 test (2026-10-13): A3 range text in schedules, A4 params rows for bearing / sole plate tables, A5 dimension-chain input, A6 handle lookup, A7 default negative-test ids. No Rhino. Needs 검증/open19_snapshot_before.json (tools/open19_snapshot.py before, taken before the changes). Web part uses its own data root 검증/open19_test_data (recreated) with a copy of data/운곡교p2. """ import copy import json import os import shutil import sys from pathlib import Path APP = Path(__file__).resolve().parents[1] TD = APP / "검증" / "open19_test_data" if TD.exists(): shutil.rmtree(TD) sys.path.insert(0, str(APP / "tools")) from testlib import pier_dir # noqa: E402 shutil.copytree(pier_dir("운곡교p2"), TD / "p2", ignore=shutil.ignore_patterns("runs", "backup")) os.environ["PIERAPP_DATA"] = str(TD) sys.path.insert(0, str(APP)) sys.path.insert(0, str(APP / "tools")) sys.path.insert(0, str(APP / "web")) from engine import reader # noqa: E402 from engine.gates import NEGATIVE, negative_moves # noqa: E402 import app as webapp # noqa: E402 import store # noqa: E402 from open19_snapshot import snapshot # 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) before = json.loads((APP / "검증" / "open19_snapshot_before.json").read_text(encoding="utf-8")) after = snapshot() (APP / "검증" / "open19_snapshot_after.json").write_text(json.dumps(after, ensure_ascii=False, indent=1), encoding="utf-8") # ---- A3: records only gain cells; Ungok P2 C12 / C13 range texts now land in column A for pier in before: added, changed = [], [] b_rec, a_rec = before[pier]["records"], after[pier]["records"] for k in b_rec: for rb, ra in zip(b_rec[k], a_rec.get(k, [])): changed += [(k, rb["_key"], f) for f in rb if ra.get(f) != rb[f]] added += [(rb["_key"], f, ra[f]) for f in ra if f not in rb] check(f"A3 {pier}: same tables / rows", b_rec.keys() == a_rec.keys() and all(len(b_rec[k]) == len(a_rec[k]) for k in b_rec)) check(f"A3 {pier}: no existing cell changed or lost", not changed, changed[:5]) print(" added cells:", added) if pier == "운곡교p2": got = {m: v for m, f, v in added if f == "A"} check("A3 p2: C12 / C13 range text in column A", got.get("C12") == "12.228~3.151" and got.get("C13") == "12.167~12.044", added) check("A3 p2: nothing else added", len(added) == 2, added) # ---- A4: expected-side rows identical to before; new params rows all equal current params for pier in before: old = before[pier]["proposals"] new = after[pier]["proposals"] new_old = [r for r in new if r["file"] == "expected" or r["path"][0] == "rebar_marks"] check(f"A4 {pier}: expected / rebar_marks rows unchanged ({len(old)})", new_old == old) pr = [r for r in new if r["file"] == "params" and r["path"][0] != "rebar_marks"] diff = [r for r in pr if not r["same"]] kinds = sorted({".".join(str(x) for x in r["path"] if not isinstance(x, int)) for r in pr}) check(f"A4 {pier}: params rows found, none differ ({len(pr)})", pr and not diff, diff[:3] or kinds) texts = json.loads((pier_dir("운곡교p2") / "drawings/texts.json").read_text(encoding="utf-8")) P0 = json.loads((pier_dir("운곡교p2") / "params.json").read_text(encoding="utf-8")) X0 = json.loads((pier_dir("운곡교p2") / "expected.json").read_text(encoding="utf-8")) cands = reader.field_candidates(reader.table_candidates(texts)) P = copy.deepcopy(P0) P["sole_plates"]["table"][2]["W"] = 999 # NO.3 W (table 750) P["bearing_seats"]["bearings"][6]["h1"] += 7 # 7th entry rows = reader.proposals(cands, P, X0) d = [r for r in rows if not r["same"]] no7 = P["bearing_seats"]["bearings"][6]["no"] check("A4 mutation: only the two changed params cells differ", sorted(tuple(r["path"]) for r in d) == sorted([("sole_plates", "table", 2, "W"), ("bearing_seats", "bearings", 6, "h1")]), [r["path"] for r in d]) check("A4 mutation: expected untouched by the params rows", all(r["file"] == "params" for r in d)) reader.apply_proposals(d, P, X0) check("A4 apply restores exactly the original params", P == P0) check("A4 h3 common value proposed once (Ungok)", sum(r["path"] == ["sole_plates", "h3"] for r in rows) == 1) # ---- A5: dimension chain pos = json.loads((APP / "data/운곡교P1/params.json").read_text(encoding="utf-8"))["footing_rebar"]["layers"]["bottom_Y1"]["pos"] incs = [b - a for a, b in zip(pos, pos[1:])] parts, i = [], 0 while i < len(incs): j = i while j < len(incs) and incs[j] == incs[i]: j += 1 parts.append(f"{j - i}@{incs[i]:g}" if j - i > 1 else f"{incs[i]:g}") i = j text = ", ".join([f"{pos[0]:g}"] + parts) print(" chain:", text) check("A5 chain of P1 bottom_Y1.pos (73) expands to the same list", store.chain(text) == pos, len(store.chain(text))) check("A5 drawing notation", store.chain("0, 8@120") == [0] + [120 * k for k in range(1, 9)]) check("A5 decimals kept", store.chain("-1.5, 2@0.25") == [-1.5, -1.25, -1.0]) for bad in ("", "a, 1", "2@100", "0, 0@5", "0, 3@x"): try: store.chain(bad) check(f"A5 rejects {bad!r}", False) except ValueError as e: check(f"A5 rejects {bad!r}", True, e) nested = {"a": {"chain": "0, 2@5"}, "b": [{"chain": "1, 1"}, 3], "c": {"chain": "0", "x": 1}} check("A5 expand_chains nested / other dicts untouched", store.expand_chains(nested) == {"a": [0, 5, 10], "b": [[1, 2], 3], "c": {"chain": "0", "x": 1}}) c = webapp.app.test_client() fr = store.Pier("p2").load("params")["footing_rebar"] fr2 = copy.deepcopy(fr) fr2["b9"]["grid_x"] = {"chain": f"{fr['b9']['grid_x'][0]}, {len(fr['b9']['grid_x']) - 1}@250"} r = c.post("/pier/p2/section/params/footing_rebar", data={"value": json.dumps(fr2, ensure_ascii=False)}) saved = store.Pier("p2").load("params")["footing_rebar"] check("A5 web save expands the chain to the same list", r.status_code == 302 and saved == fr, (r.status_code, saved["b9"]["grid_x"][:3])) fr2["b9"]["grid_x"] = {"chain": "0, 3@abc"} h = c.post("/pier/p2/section/params/footing_rebar", data={"value": json.dumps(fr2, ensure_ascii=False)}, follow_redirects=True).get_data(as_text=True) check("A5 web rejects a bad chain, params unchanged", "치수 사슬 형식 오류" in h and store.Pier("p2").load("params")["footing_rebar"] == fr) check("A5 section page shows the chain hint", "치수 사슬" in c.get("/pier/p2/section/params/footing_rebar").get_data(as_text=True)) # ---- A6: handle lookup (values written in the P2 reading report / params src) dxf = pier_dir("운곡교p2") / "drawings/dxf" d1 = next(a["dxf"] for a in json.loads((pier_dir("운곡교p2") / "drawings/audit.json").read_text(encoding="utf-8")) if a["label"] == "D1") e = reader.entity_info(dxf / d1, "21A76") print(" 21A76:", e) check("A6 21A76 (footing, report: 10.350) found", e and abs(max(e["values"].get("bbox_size", [0])) - 10350) < 1, e) e = reader.entity_info(dxf / d1, "20e89") print(" 20E89:", e) check("A6 20E89 (coping soffit arc, params R12,000) is an ARC R12000", e and e["type"] == "ARC" and abs(e["values"]["radius"] - 12000) < 1, e) check("A6 unknown handle -> None", reader.entity_info(dxf / d1, "FFFFFFF") is None) h = c.get("/pier/p2/search?dwg=D1&h=20E89").get_data(as_text=True) check("A6 web shows the object", "ARC" in h and "radius" in h and "12000" in h) h = c.get("/pier/p2/search?dwg=D1&h=ZZZ").get_data(as_text=True) check("A6 web reports a missing handle", "핸들 'ZZZ' 객체가 없습니다" in h) # ---- A7: default negative ids follow the bearings of the pier X10 = json.loads((APP / "data/운곡교P1/expected.json").read_text(encoding="utf-8")) check("A7 10 bearings: defaults unchanged", all(negative_moves(X10, g) == NEGATIVE[g] for g in NEGATIVE)) X4 = copy.deepcopy(X10) X4["G5"]["el_x"] = {k: v for k, v in X4["G5"]["el_x"].items() if int(k) <= 4} m = {g: [mv.get("id") or mv.get("mark") for mv in negative_moves(X4, g)] for g in NEGATIVE} check("A7 4 bearings: ids within 1..4", m["G1"] == ["BS04", "BP03"] and m["G5"] == ["BB04", "SP04"] and m["G2"] == ["P2", "P3"], m) X4["negative"] = {"G1": [{"id": "BS01", "d": [3, 0, 0]}, {"id": "BP01", "d": [0, 0, 2]}]} check("A7 pier override still wins", [mv["id"] for mv in negative_moves(X4, "G1")] == ["BS01", "BP01"]) shutil.rmtree(TD) print("RESULT", "PASS" if ok else "FAIL") sys.exit(0 if ok else 1)