# -*- coding: utf-8 -*- """PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위. - 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch. - 260-2 미리 넣음: 횡단배수 주인 측점 원형 마크 · 빈 구조물 · 옛 글 옮김 (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림) · 오른쪽 글 = 구조물 조합 줄(265-2 · `test_265_note_cad.py`) — 옛 관보호공 · 관유출부보강 짜 맞춘 글은 버림. ./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q """ import json import subprocess import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 HERE = Path(__file__).resolve().parent TEXT = { "id": "t1", "type": "Text", "lineColor": "#ff7f7f", "lineWidth": 1, "layerId": "기본", "shapeData": {"label": "NO.71+13", "basePoint": {"x": 15, "y": -1.5}, "options": {}}, } CAD = {"entities": [TEXT], "layers": [{"id": "기본", "name": "기본"}]} # ── 260-1 칸 ──────────────────────────────────────────────────────────── def test_CAD_칸은_patch_스키마를_지나_그대로_남는다(): dumped = CrossSectionPatch(chainage_m=40.0, structure_note_cad=CAD).model_dump() merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} assert merged == {"structure_note_cad": CAD} # 빈 엔티티 = 비움 — 걸러지지 않고 실림(옛 글 대신 아무것도 안 놓음) empty = CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": []}).model_dump() assert empty["structure_note_cad"] == {"entities": [], "layers": None} # 안 보냄 = 안 바꿈 assert CrossSectionPatch(chainage_m=40.0).model_dump()["structure_note_cad"] is None def test_CAD_칸은_엔티티_수_상한(): with pytest.raises(ValueError): CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": [TEXT] * 2001}) def test_CAD_칸은_재계산에서도_남는다(monkeypatch): assert "structure_note_cad" in design_mod.USER_TOUCHED_KEYS monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) monkeypatch.setattr( design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} ) sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 assert sections[0]["design"]["structure_note_cad"] == CAD def test_브라우저가_CAD_칸을_저장_patch_에_싣는다(): read = lambda rel: (PROJECT_ROOT / rel).read_text(encoding="utf-8") # noqa: E731 persist = read("B06_Section/B06_Section_UI_Page_Persist.ts") assert "readStructureNoteCads(" in persist and ".structure_note_cad = cad" in persist state = read("A00_Common/b_page_state.ts") assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state # 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록 assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts") # 모달 = 빈 CAD(캔버스만) · 글 상자 없음 detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts") assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail assert "bare: true" in read("B06_Section/B06_Section_UI_Cross_Detail_Cad.ts") # ── 260-2 미리 넣음 ───────────────────────────────────────────────────── @pytest.fixture(scope="module") def out() -> dict: proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_260_note_cad.cjs")], capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def _labels(entities: list[dict]) -> list[str]: return [entity["label"] for entity in entities if entity["type"] == "Text"] def test_관_유입_유출_도수로_한_벌(out): """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 구조물 조합 줄(265-2).""" full = out["full"] assert [entity["type"] for entity in full[:2]] == ["Circle", "Text"] # 지시선 없음(169) assert _labels(full) == [ "배수관", "Φ1200m/m", "L=22m", "NO.71+13", "기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임", ] # 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층 inside, right = full[1:4], full[4:] assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])} assert [e["y"] for e in right] == [-1.5, -4.5, -7.5] assert {e["x"] for e in right} == {15} assert {e["align"] for e in right} == {"left"} assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"} def test_관_길이_없으면_L_뒤_빈칸(out): assert _labels(out["noLength"])[2] == "L=" # 조합 줄이 없으면 측점 줄까지 assert _labels(out["noLength"])[3:] == ["NO.71+13"] def test_유입_집수정_은_형태_높이_길이(out): # 265-2 — 보호공 짜 맞춘 글 버림(조합 줄로) · 원형 마크 · 측점만 assert _labels(out["basin"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] def test_도수로_옵션이_없으면_세트_구조만(out): # 265-2 — 관유출부보강 짜 맞춘 글 버림 assert not any(label.startswith("관유출부보강") for label in _labels(out["chuteFallback"])) def test_보호공을_안_고르면_관과_측점만(out): assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] @pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) def test_횡단배수_주인_측점이_아니면_빈값(out, case): """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관 · 이웃 측점 = 빈 도면(줄 없음).""" assert out[case] == [] def test_마크_없이_조합_줄만이면_측점부터_왼쪽_끝(out): lines = out["linesOnly"] assert _labels(lines) == ["NO.71+13", "기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"] assert {e["x"] for e in lines} == {0} and [e["y"] for e in lines] == [-1.5, -4.5, -7.5] def test_옛_글은_줄마다_Text_로_옮김(out): legacy = out["legacy"] assert _labels(legacy) == ["L형 측구", "집수정 보강"] # 빈 줄은 자리만 — B07 옛 글 줄과 같은 높이(줄 3.0 · 가운데) assert [e["y"] for e in legacy] == [-1.5, -7.5] and {e["x"] for e in legacy} == {0} assert out["legacyEmpty"] == 0 def test_측점_이름은_B07_꼴(out): assert out["station"] == ["NO.71+13", "NO.71", "NO.72"] # ── 260-3 B07 표 위 ───────────────────────────────────────────────────── from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( # noqa: E402 CROSS_TABLE_LAYER_ID, FRAME_LAYER_ID, build_cross_drawing, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( # noqa: E402 cross_note_block_height, cross_note_cad, note_cad_bbox, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox # noqa: E402 _SOURCE = { "chainage_m": 20.0, "samples": [ {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.3 * x} for x in range(-10, 11) ], } _QTABLE = {"ground": 100.0, "planned": 99.5} def _text(label: str, x: float, y: float, align: str = "left", font: float = 2.0) -> dict: return { "id": label, "type": "Text", "lineColor": "#ff7f7f", "lineWidth": 1, "layerId": "기본", "shapeData": { "label": label, "basePoint": {"x": x, "y": y}, "options": {"textAlign": align, "fontSize": font, "textColor": "#ff7f7f"}, }, } # 미리 넣음과 같은 꼴 — 원(중심 6.5,-9 · 반지름 6.5) · 지시선 · 오른쪽 글(도면 좌표 아무 자리여도 됨) _CIRCLE = { "id": "c", "type": "Circle", "lineColor": "#ff7f7f", "lineWidth": 1, "layerId": "기본", "shapeData": {"center": {"x": 106.5, "y": 41.0}, "radius": 6.5}, } _CAD = { "structure_note_cad": { "entities": [_CIRCLE, _text("NO.1", 115.0, 48.5), _text("관유출부보강", 115.0, 45.5)], "layers": [{"id": "기본", "name": "기본"}], } } def _draw(design: dict | None) -> dict: return build_cross_drawing(_SOURCE, "cross_00020m", None, design, _QTABLE, "No.1") def _table(drawing: dict) -> dict: return next(e for e in drawing["entities"] if e["type"] == "Table") def _frame_height(drawing: dict) -> float: frame = [e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID] _x0, y0, _x1, y1 = entities_bbox(frame) return y1 - y0 def test_CAD_칸_범위는_글_높이_폭까지(): """글 기준점 = 세로 가운데 · 왼쪽 맞춤 폭 = 한글 글자 크기 · 원 = 중심 ± 반지름.""" box = note_cad_bbox(_CAD["structure_note_cad"]["entities"]) assert box == pytest.approx((100.0, 34.5, 115.0 + 6 * 2.0, 49.5)) assert note_cad_bbox([_text("AB", 10.0, 0.0, "center")]) == pytest.approx((8.8, -1, 11.2, 1)) assert note_cad_bbox([]) is None assert cross_note_cad(None) is None and cross_note_cad({"structure_note": "x"}) is None def test_CAD_칸은_표_왼쪽_위_맞춤_표는_범위_높이만큼_내려감(): plain = _draw(None) drawn = _draw(_CAD) height = 49.5 - 34.5 assert cross_note_block_height(_CAD) == pytest.approx(height) plain_top = _table(plain)["shapeData"]["origin"]["y"] drawn_top = _table(drawn)["shapeData"]["origin"]["y"] assert plain_top - drawn_top == pytest.approx(height) placed = [e for e in drawn["entities"] if e.get("id") in _placed_ids()] left = _table(drawn)["shapeData"]["origin"]["x"] box = note_cad_bbox(placed) # 좌상 맞춤 — 범위 왼쪽 = 표 왼쪽 · 범위 위 = 옛 표 윗변(글 없을 때 표 자리) assert box[0] == pytest.approx(left) and box[3] == pytest.approx(plain_top) assert box[1] == pytest.approx(drawn_top) # 표 층으로 · 크기 그대로(배율 1) · 글 색 그대로 · id 는 도면마다 새로 assert {e["layerId"] for e in placed} == {CROSS_TABLE_LAYER_ID} circle = next(e for e in placed if e["type"] == "Circle") assert circle["shapeData"]["radius"] == 6.5 assert {e["lineColor"] for e in placed} == {"#ff7f7f"} assert "c" not in {e["id"] for e in placed} # 블록(테두리) · 시트 칸 크기도 같은 규칙 assert _frame_height(drawn) - _frame_height(plain) == pytest.approx(height) _w, plain_h = section_block_size(_SOURCE, None) _w, cad_h = section_block_size(_SOURCE, None, design=_CAD) assert cad_h - plain_h == pytest.approx(height) _w, scaled_h = section_block_size(_SOURCE, None, design=_CAD, table_scale=2.0) _w, plain2_h = section_block_size(_SOURCE, None, table_scale=2.0) assert scaled_h - plain2_h == pytest.approx(2 * height) def _placed_ids() -> set[str]: """옮겨 놓은 CAD 칸 엔티티 id — 결정적 seed `{도면}:notecad:{n}`.""" from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _ENTITY_NS return {str(uuid5(_ENTITY_NS, f"cross_00020m:notecad:{n}")) for n in range(3)} def test_CAD_칸이_있으면_옛_글_대신(): both = _draw({**_CAD, "structure_note": "옛 글"}) labels = [e["shapeData"]["label"] for e in both["entities"] if e["type"] == "Text"] assert "옛 글" not in labels and "NO.1" in labels def test_CAD_칸이_비면_아무것도_안_놓음_옛_글도_안_씀(): plain = _draw(None) empty = _draw({"structure_note_cad": {"entities": []}, "structure_note": "옛 글"}) assert _table(plain)["shapeData"] == _table(empty)["shapeData"] assert not [e for e in empty["entities"] if e["type"] == "Text"] def test_CAD_칸이_없으면_옛_글_줄_그대로(): noted = _draw({"structure_note": "L형 측구\n집수정 보강"}) labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"] assert labels == ["L형 측구", "집수정 보강"] # ── 260-4 안 연 측점 = B07 이 파이썬 짝으로 넣음 ───────────────────────── from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 load_note_sources, note_entities, station_no_label, structure_note_lines, with_structure_note, ) _OPTIONS = { "pipe_diameter_mm": 1200, "inlet_type": "기슭막이", "inlet_revet_form": "돌쌓기(찰)", "inlet_revet_height_m": 2, "inlet_revet_length_m": 10, "outlet_type": "기슭막이", "outlet_revet_form": "돌쌓기(메)", "outlet_revet_height_m": 2, "outlet_revet_length_m": 10, "chute_add_1_structure": "돌붙임", "chute_add_1_bond": "찰붙임", "chute_add_1_area": 10, } _NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")} def _item(key: str, name: str) -> dict: return {"layer": "system", "owner": None, "key": key, "name": name} # 관 조합 — 본체(「관」) · 유입 · 유출 · 도수로(265-2 · 본체 줄은 원형 마크가 대신) _PIPE_THING = { "name": "배관", "pipe": True, "at": 1433.0, "group_items": { "grv6lny": _item("SS000001", "흄관 1200"), "gnbf9m9": _item("SS000055", "기슭막이 메쌓기 1.2m 메쌓기 기초무"), "gchuteadd1": _item("SS000070", "돌붙임 찰붙임"), }, } def _case( options: dict, pipe_at: float, chainage: float, design: dict | None, things=(), **spec ) -> dict: culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec} return { "section": {"chainage_m": chainage, "culvert": culvert, "design": design}, "station": station_no_label(chainage, 20.0), "things": list(things), "chainage": chainage, "interval": 20.0, } _MIRROR_CASES = [ _case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}, [_PIPE_THING]), _case(_NO_CHUTE, 1433.0, 1433.0, {}), _case(_NO_CHUTE, 1433.0, 1433.0, {"pipe_length_m": 9.5}), _case(_NO_CHUTE, 80, 80, None, [{**_PIPE_THING, "at": 80.0}]), _case({**_OPTIONS, "pipe_diameter_mm": 800}, 12.5, 12.5, {}, [{**_PIPE_THING, "at": 12.5}]), _case(_OPTIONS, 440.241, 440.0, {"pipe_length_m": 7}, [{**_PIPE_THING, "at": 440.241}]), _case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}, [_PIPE_THING]), _case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"), _case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True), ] def _without_id(entities: list[dict]) -> list[dict]: return [{k: v for k, v in entity.items() if k != "id"} for entity in entities] def _mirror(cases: list[dict]) -> list[dict]: proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], input=json.dumps(cases, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티(): mirrored = _mirror(_MIRROR_CASES) assert len(mirrored) == len(_MIRROR_CASES) for case, ts in zip(_MIRROR_CASES, mirrored, strict=True): section = case["section"] lines = structure_note_lines(case["things"], section["chainage_m"]) assert lines == ts["lines"], case["station"] py = note_entities( section["chainage_m"], section["culvert"], section["design"], case["station"], lines ) assert _without_id(py) == _without_id(ts["entities"]), case["station"] assert station_no_label(case["chainage"], case["interval"]) == ts["station"] # 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값 assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3 labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]] assert "Φ800m/m" in labels and "돌붙임 찰붙임" in labels and "흄관 1200" not in labels def _write_pipes(root: Path, points: list[dict]) -> None: path = pipe_points_file(root) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8") def test_정본_배수관만_읽음(tmp_path): _write_pipes( tmp_path, [ {"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}, {"chainage_m": 80.0, "lonlat": [0, 0], "facility": "pipe", "options": {}}, {"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}}, ], ) pipes, things = load_note_sources(tmp_path) assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼 assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS # 글 줄 재료 = 관 지점 모두(측점 차례 · 시설 이름 = 구조물정의 종류 이름) assert [(t["name"], t["pipe"], t["at"]) for t in things] == [ ("배관", True, 80.0), ("세월교", False, 90.0), ("배관", True, 1433.0), ] assert load_note_sources(tmp_path / "없음") == ([], []) def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path): _write_pipes( tmp_path, [{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}], ) sources = load_note_sources(tmp_path) design = {"pipe_length_m": 22, "paved": False} seeded = with_structure_note(design, 1433.0, sources, "NO.71+13") assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜 labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]] # 관 조합 미지정 = 원형 마크 · 측점(배관은 조합 글 줄 없음) · 이어서 좌측 패널 옵션 줄(166-1) assert labels[1:5] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] assert labels[5].startswith("관: 관경 1200") and all(": " in line for line in labels[5:]) cleared = {**design, "structure_note_cad": {"entities": []}} assert with_structure_note(cleared, 1433.0, sources, "x") is cleared old = {**design, "structure_note": "옛 글"} assert with_structure_note(old, 1433.0, sources, "x") is old assert with_structure_note(None, 1433.0, sources, "x") is None assert with_structure_note(design, 1440.0, sources, "x") is design def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path): _write_pipes( tmp_path, [ { "chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS, "group_items": {"g9lq9uk": _item("SS000055", "기슭막이 메쌓기 1.2m")}, } ], ) design = with_structure_note({"pipe_length_m": 22}, 20.0, load_note_sources(tmp_path), "NO.1") drawn = _draw(design) labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"] assert "NO.1" in labels and "기슭막이 메쌓기 1.2m" in labels plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"] drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"] assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15