Files
Aislo/resources/tester/test_323_4_attach_mirror.py
T
eomsangdonandClaude Opus 5.5 403722e687 refactor(B05·B06): 소단 = 옹벽처럼 측점에 제원 얹음 — 형상 얹기 한 벌 · 세션 사본 berm 걷음 (PLAN 577-2 · 요구 434 · 437)
- 얹기: attachShapes · attach_shapes 에 소단(section.berm · 덮는 소단 없음 = null) · SHAPE_KEYS 에 berm · TS attachBermSpecs ↔ 파이썬 attach_berm_specs · berm_spans_from 짝
- 읽기: 저장 노선 다시 셈(브라우저 · Node cross.refresh) = 얹힌 section.berm(칸 없음 = 저장분 design.berm) · 노선 초안 · 서버 Node 묶음 cross = crossWithShapes 의 얹힌 제원(bermAt · crossWithBerm 걷음)
- 계단은 계산 안쪽 그대로 · [저장] 길(구조물 정본 → cross.berm_spans) 그대로
- 지움: 시설 줄 syncBerm · putBerm(C4.copy) · writeBermSpans · 세션 칸 berm · bermRequest · design_shared bermAt
- 시험: test_272_berm_source 측점 얹기 거울로 · test_323_4 소단 얹음 · 477 · 511-A · 472 떠남 · 365 · 272 고름 헬퍼가 사본 대신 얹힌 소단 · design_pipeline_page 700 줄 → 699(주석 한 줄)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:33:06 +09:00

223 lines
8.6 KiB
Python

"""PLAN 323-4 형상 얹기 한 벌 — 파이썬 `attach_shapes` = TS `attachShapes`(거울) · 저장 전 그림 = 저장 뒤 그림.
- 42종 견본(관 지점 · 구조물 · 정의 기본값) + 빈칸 견본 → 측점마다 그림 자료(관 세트 · 벽 · 포장 · 맹암거) ·
빈칸 경고 `blank`(「종류 칸」 · 측점 계획 자리)가 서버 = 화면.
- 옛 열쇠는 걷고 다시(물넘이포장을 옮기면 옛 자리 파임이 빠짐 · 걸친 측점에 새로).
- 3D — C군 벽은 덮는 측점 중 기준 측점에서만 시작 ~ 끝 스윕 · 지문 = 그림 자료 통째(손 해시가 빠뜨린 칸도).
TS 쪽 = `helper_323_4_attach.cjs`(Node).
"""
from __future__ import annotations
import copy
import json
import subprocess
import tempfile
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition
from B06_Section.B06_Section_Engine_Attach import attach_shapes
from common_util.common_util_drainage_pipes import parse_pipe_points
from common_util.common_util_structure_catalog import catalog
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
TYPES = with_visible().model_dump()["types"]
def _defaults(type_id: str) -> dict:
"""정의 기본값(빈 값 뺌) — 그림이 서는 견본."""
options = next(t for t in TYPES if t["type_id"] == type_id)["options"]
return {o["key"]: o["default"] for o in options if o["default"] not in (None, "")}
def _samples() -> tuple[list[dict], list[dict]]:
"""42종 하나씩(30 m 간격 · 구간형 = 기준 ± 5) + 빈칸 셋(관경 · 옹벽 높이 · 맹암거 높이)."""
pipes: list[dict] = []
structures: list[dict] = []
rows = sorted(catalog().values(), key=lambda row: row["typeId"])
for index, row in enumerate(rows):
at = 15.0 + 30.0 * index
options = _defaults(row["typeId"])
if row["owner"] == "pipe_points":
pipes.append({"chainage_m": at, "facility": row["typeId"], "options": options})
continue
span = {"start_m": at - 5, "end_m": at + 5} if row["placement"] == "interval" else {}
structures.append(
{
"structure_id": f"s{index}",
"type_id": row["typeId"],
"placement": row["placement"],
"chainage_m": at,
"options": options,
**span,
}
)
end = 15.0 + 30.0 * len(rows)
pipes.append({"chainage_m": end, "facility": "pipe", "options": {"pipe_diameter_mm": ""}})
structures.append(
{
"structure_id": "blank-wall",
"type_id": "retaining_wall",
"placement": "interval",
"start_m": end + 25,
"chainage_m": end + 30,
"end_m": end + 35,
"options": {},
}
)
structures.append(
{
"structure_id": "blank-drain",
"type_id": "cross_underdrain",
"placement": "point",
"chainage_m": end + 60,
"options": {},
}
)
return pipes, structures
def _sections(until: float) -> list[dict]:
"""5 m 마다 측점(모든 기준 · 시작 · 끝 자리) — 옛 열쇠를 미리 얹어 걷힘도 봄."""
out = []
for step in range(int(until // 5) + 2):
section = {"chainage_m": float(step * 5)}
if step == 1:
section["ford_pavement"] = {"type": "ford_pavement", "chainage_m": 5.0} # 옛 자리
out.append(section)
return out
PIPES, STRUCTURES = _samples()
UNTIL = 15.0 + 30.0 * len(catalog()) + 60
def _moved() -> dict:
"""458 노선 바뀜 — 노선 x 축 0 ~ 100 · 측점 10 m 마다(좌표 있음).
관 30.4(주인 30 · 0.4 m) · 44(가장 가까운 측점 4 m = 측점 없음) · 0(좌표 -3 = 시점 밖) ·
100(좌표 100.3 · 옆 1.5 = 종점 안) · 물넘이포장 70(폭 24 = 60 · 80 연동) · 55(주인 5 m = 연동도 안 얹음).
"""
pipe = _defaults("pipe")
pave = {**_defaults("ford_pavement"), "ford_width_m": 24}
sections = [
{"chainage_m": float(at), "center_x": float(at), "center_y": 0.0}
for at in range(0, 101, 10)
]
pipes = [
{"id": "a", "chainage_m": 30.4, "x": 30.4, "y": 0.0, "facility": "pipe", "options": pipe},
{"id": "b", "chainage_m": 44.0, "x": 44.0, "y": 0.0, "facility": "pipe", "options": pipe},
{"id": "c", "chainage_m": 0.0, "x": -3.0, "y": 0.0, "facility": "pipe", "options": pipe},
{"id": "d", "chainage_m": 100.0, "x": 100.3, "y": 1.5, "facility": "pipe", "options": pipe},
{"id": "e", "chainage_m": 70.0, "facility": "ford_pavement", "options": pave},
{"id": "f", "chainage_m": 55.0, "facility": "ford_pavement", "options": pave},
]
return {"sections": sections, "pipes": pipes, "structures": []}
CASES = {
"all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES},
"empty": {"sections": _sections(30), "pipes": [], "structures": []},
"moved": _moved(),
}
@pytest.fixture(scope="module")
def ts() -> dict:
payload = {"definition": load_definition(), "types": TYPES, "cases": CASES}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "input.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
out = subprocess.run(
["node", str(HERE / "helper_323_4_attach.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(out.stdout)
def _py(case: dict) -> tuple[list[dict], dict]:
sections = copy.deepcopy(case["sections"])
pipes = parse_pipe_points(case["pipes"])
structures = [StructureInstance.model_validate(s) for s in case["structures"]]
misses = attach_shapes(sections, pipes, structures)
return json.loads(json.dumps(sections)), misses
def test_mirror_python_equals_ts(ts: dict) -> None:
for name, case in CASES.items():
(py, misses), js = _py(case), ts["cases"][name]
assert misses == ts["misses"][name], name
assert len(py) == len(js), name
for left, right in zip(py, js):
assert left == right, (name, left["chainage_m"])
def test_owner_cap_and_outside(ts: dict) -> None:
"""458 — 주인 측점 0.51 m 상한 · 시종점 밖 = 안 얹고 경고 · 연동 이웃 = 폭 절반."""
by_at = {s["chainage_m"]: s for s in ts["cases"]["moved"]}
assert ts["misses"]["moved"] == {"outside": [0.0], "far": [44.0, 55.0]}
assert by_at[30.0]["culvert"]["chainage_m"] == 30.4
assert by_at[100.0]["culvert"]["chainage_m"] == 100.0 # 종점 0.3 m 넘음 · 옆 1.5 m = 노선 안
assert "culvert" not in by_at[0.0] and "culvert" not in by_at[40.0]
assert "culvert" not in by_at[50.0]
linked = {at for at, s in by_at.items() if "ford_pavement" in s}
assert linked == {60.0, 70.0, 80.0}
assert all(by_at[at]["ford_pavement"]["chainage_m"] == 70.0 for at in linked)
def test_every_shape_lands(ts: dict) -> None:
keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"}
assert {
"culvert",
"ford",
"box",
"ford_pavement",
"revetment",
"pavement",
"underdrain",
"berm",
} <= keys
assert any(section.get("berm") for section in ts["cases"]["all"]) # 소단 = 측점에 얹음(577-2)
old = ts["cases"]["all"][1] # 옛 물넘이 자리(5 m) — 걷힘
assert "ford_pavement" not in old
# 구조물 없음 = 소단 「얹었는데 없음」(null)만
assert all(set(section) == {"chainage_m", "berm"} for section in ts["cases"]["empty"])
assert all(section["berm"] is None for section in ts["cases"]["empty"])
def test_blank_chips(ts: dict) -> None:
end = 15.0 + 30.0 * len(catalog())
by_at = {s["chainage_m"]: s.get("blank") for s in ts["cases"]["all"]}
assert by_at[end] == ["배관 관경"]
assert by_at[end + 25] == by_at[end + 30] == by_at[end + 35] == ["옹벽 높이"]
assert by_at[end + 60] == ["맹암거 암거 높이"]
assert by_at[end + 5] is None # 계획 자리만
def test_wall_sweeps_once(ts: dict) -> None:
assert ts["sweep"] == [None, None, {"beforeM": 8, "afterM": 12}, None]
def test_fingerprint_covers_drawing(ts: dict) -> None:
assert ts["fingerprint"] == {
"same": True,
"foundation": True,
"pavement": True,
"underdrain": True,
"adjust": True,
"plain": True,
}