# -*- coding: utf-8 -*- """234-1 · 272-1 구조물 · 관 기준 측점 = 정수 m(반올림) · 노선 끝 안쪽. 요구 167 — 측점 값에 소수를 안 씀 · 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수). 9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈 (사용자 확정 10-10 · 조사 AF 7절 A+B+C). 맞춤 규칙 한 벌 = 파이썬 `common_util_structure_station.py` ↔ TS `common_util_structure_station.ts`(거울). 화면 쪽 진짜 TS(폼 칸 · 구조물 옮김 · 단추 · 배수유역도 관 · 계획고 편집 따라감)는 `helper_234_1_structure_station.cjs` 가 Node 로 돌린다. 서버 자동 배치 · 노선 재투영은 파이썬으로. """ from __future__ import annotations import json import subprocess import sys from pathlib import Path import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 from common_util.common_util_route_geometry import RouteVertex, StructureCandidate # noqa: E402 from common_util.common_util_structure_station import ( # noqa: E402 general_stations, snap_candidates, snap_structure_station, ) TOTAL = 279.8 STATIONS = general_stations(TOTAL, 20.0) # (입력, 기대) — 소수 → 정수(반올림) · 끝(소수 279.8)은 넘지 않음 CASES = [ (80.17, 80.0), # 관 80.17 = 카드 80 한 자리 (80.6, 81.0), # 그냥 반올림(일반 측점 붙기 없음 · 167) (80.99, 81.0), (81.0, 81.0), # 꼭 1 m 는 밖 — 81 에 둘 수 있음(방향키가 갇히지 않음) (50.5, 51.0), # .5 는 올림(파이썬 · TS 같은 꼴) (50.4, 50.0), (263.31, 263.0), (263.206, 263.0), (127.86, 128.0), (0.3, 0.0), (279.3, 279.0), # 끝 279.8 안쪽 정수 (279.7, 279.0), # 반올림 280 은 끝 밖 → 279 (278.6, 279.0), (277.4, 277.0), ] STEPS = [ ((80.0, 1), 81.0), ((80.17, 1), 81.0), ((80.17, -1), 79.0), ((279.8, -1), 279.0), # 옛 끝 소수 → 안쪽 정수 ((279.8, 1), 279.8), # 노선 밖은 제자리 ((0.0, -1), 0.0), ] STRUCTURES = [ {"structure_id": "sign", "placement": "point", "chainage_m": 127.86}, { "structure_id": "wall", "placement": "interval", "chainage_m": 80.17, "start_m": 75.17, "end_m": 95.67, }, {"structure_id": "ok", "placement": "point", "chainage_m": 40.0}, {"structure_id": "near", "placement": "point", "chainage_m": 20.4}, ] PIPES = [80.17, 120.0, 120.3, 263.31, 140.0] pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "typescript").is_dir(), reason="프론트엔드 의존성 미설치", ) @pytest.fixture(scope="module") def ts(tmp_path_factory) -> dict: source = tmp_path_factory.mktemp("s234") / "in.json" source.write_text( json.dumps( { "stations": STATIONS, "samples": [value for value, _ in CASES], "steps": [list(args) for args, _ in STEPS], "structures": STRUCTURES, "pipes": PIPES, } ), encoding="utf-8", ) proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_234_1_structure_station.cjs"), str(source)], capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), check=False, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def test_general_stations() -> None: assert STATIONS == [float(index * 20) for index in range(14)] + [279.8] @pytest.mark.parametrize(("value", "expected"), CASES) def test_snap_python(value: float, expected: float) -> None: assert snap_structure_station(value, STATIONS) == expected # 두 번 맞춰도 같음 assert snap_structure_station(expected, STATIONS) == expected def test_mirror_python_ts(ts) -> None: assert ts["snap"] == [snap_structure_station(value, STATIONS) for value, _ in CASES] assert ts["snapTwice"] is True def test_arrow_step_one_metre(ts) -> None: assert ts["step"] == [expected for _, expected in STEPS] def test_general_registry_skips_irregular(ts) -> None: assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님 assert ts["defaultSnap"] == 80 def test_form_input_integer(ts) -> None: assert ts["remainderStep"] == "1" assert ts["typed"] == 80 # 입력 4+0.17 = 80.17 → 80 assert ts["typedNear"] == 81 # 4+0.6 → 반올림 81(붙기 없음 · 167) assert ts["typedOne"] == 81 assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(열 때 자동 맞춤 몫 · 272-4) def test_moved_structure_range_ends_integer(ts) -> None: assert ts["movedPoint"]["chainage_m"] == 128 wall = ts["movedWall"] assert (wall["chainage_m"], wall["start_m"], wall["end_m"]) == (80, 75, 96) drag = ts["movedDrag"] assert (drag["chainage_m"], drag["start_m"], drag["end_m"]) == (133, 133, 145) def test_old_station_auto_snap_plan_and_run(ts) -> None: """272-4 — 단추 없이 종단 보기를 열 때 한 번 · 확인 창 없음 · 알림 한 줄.""" plan = ts["plan"] assert [entry["index"] for entry in plan["structures"]] == [0, 1, 3] assert plan["pipes"] == [[80.17, 80], [263.31, 263]] assert plan["skipped"] == 1 # 120.3 → 120 자리에 관이 이미 있음 run = ts["run"] assert run["count"] == 5 and run["emits"] == 1 and run["closed"] == 1 assert [s["chainage_m"] for s in run["structures"]] == [128, 80, 40, 20] assert (run["structures"][1]["start_m"], run["structures"][1]["end_m"]) == (75, 96) # 관은 하나씩 폼 [수정]과 같은 길 — 시설 속성이 그대로 실림 assert [(m["from"], m["to"]) for m in run["moves"]] == [(80.17, 80), (263.31, 263)] assert run["moves"][0]["attributes"]["options"] == {"pipe_kind": "흄관"} assert run["moves"][0]["attributes"]["facility"] == "pipe" assert run["confirmText"] is None # 확인 창 없음 assert run["toasts"][-1]["message"].startswith("옛 소수 측점 5건을 정수 m 로 옮겼습니다") assert "1건은 그대로" in run["toasts"][-1]["message"] # 두 번째 — 옮길 것 없음(겹친 관만 알림) assert ts["second"]["count"] == 0 assert "맞출 자리에 다른 관이 있는 관 1건" in ts["second"]["toasts"][0]["message"] def test_plan_edit_follows_batch_move(ts) -> None: # 213-2 규칙만으로는 여럿을 한꺼번에 옮기면 못 따라감(null) → 맞춘 자리로 따라감 assert ts["follow"] == [{"old": None, "next": 80}, {"old": None, "next": 263}] def test_drainage_map_pipe_integer(ts) -> None: assert ts["editor"] == [141, 201] # 옮김 141.37 → 141 · 추가 200.6 → 201 def _vertices() -> list[RouteVertex]: return [RouteVertex(x=0.0, y=0.0, z=0.0, chainage_m=0.0), RouteVertex(TOTAL, 0.0, 0.0, TOTAL)] def test_server_auto_placement_integer() -> None: vertices = _vertices() placed = [ StructureCandidate(chainage_m=80.374, x=80.374, y=0.0, reason="stream"), StructureCandidate(chainage_m=150.6, x=150.6, y=0.0, reason="spacing"), StructureCandidate(chainage_m=150.9, x=150.9, y=0.0, reason="spacing"), ] snapped = snap_candidates(placed, vertices) assert [(p.chainage_m, p.x, p.reason) for p in snapped] == [ (80.0, 80.0, "stream"), (151.0, 151.0, "spacing"), # 같은 자리로 모인 둘째는 버림 ] def test_server_reprojection_integer() -> None: points = [PipePoint(chainage_m=70.0, x=80.374, y=3.0, start_m=75.0, end_m=85.0)] moved = project_pipe_points(points, _vertices()) assert moved[0].chainage_m == 80.0 assert (moved[0].start_m, moved[0].end_m) == (85.0, 95.0) # 구간은 같은 만큼 밀림 def test_wiring_source() -> None: """받는 곳마다 맞춤이 걸렸는지(짝 머리 · 폼 · 끌기 · 방향키 · 관 · 단추).""" def read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") assert "common_util_structure_station.py" in read( "common_util/common_util_structure_station.ts" ) assert "common_util_structure_station.ts" in read( "common_util/common_util_structure_station.py" ) assert "movedStructure(structures[index], toChainageM)" in read( "B05_Profile/B05_Profile_UI_Structures_Panel.ts" ) assert "snapOldStations({" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") assert "panel.structures.snapOld()" in read("A00_Common/design/design_view_profile.ts") assert "stepStructureStation(fromM" in read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") b06 = read("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") assert "const to = snapStructureStation(toChainageM)" in b06 assert "Math.abs(fromChainageM - toChainageM) > 0.005" in b06 assert "setGeneralStations" in read("B06_Section/B06_Section_Section_Store.ts") assert "snap_candidates(" in read("common_util/common_util_drainage_detail.py") for rel in ( "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "B05_Profile/B05_Profile_UI_IrregularStations.ts", "B05_Profile/B05_Profile_UI_Drainage_Parts.ts", "B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts", ): assert "< 0.51" not in read(rel), rel # 찾는 폭 = PIPE_MATCH_M 한 값