"""계곡 통과 시설 확장 — PipePoint의 시설종류·구간·옵션 (PLAN 2026-08-17 컨테이너 병합 1단계). 배관 정본 `pipe_points.json`은 유역 계산의 입력이라 저장소를 옮기지 않고 제자리 확장한다. 구 파일(`{chainage_m, source}`뿐)은 배관·폭 미지정으로 읽혀야 한다 (하위 호환). """ import pytest from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_BRIDGE, PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, PIPE_SOURCE_STREAM, PIPE_SOURCE_USER, PipePoint, carry_facility_attributes, parse_pipe_points, ) # ── 하위 호환 — 구 파일 형식 ──────────────────────────────────────────────── def test_legacy_dict_defaults_to_pipe_with_no_span(): points = parse_pipe_points([{"chainage_m": 120.0, "source": "stream"}]) assert len(points) == 1 point = points[0] assert point.facility == PIPE_FACILITY_PIPE assert point.start_m is None and point.end_m is None assert point.source == PIPE_SOURCE_STREAM def test_legacy_bare_number_still_parses(): points = parse_pipe_points([80.5]) assert points[0].chainage_m == 80.5 assert points[0].facility == PIPE_FACILITY_PIPE def test_legacy_dict_roundtrip_stays_compact(): """구 형식 저장분은 확장 필드 없이 그대로 다시 저장된다 (파일 불변).""" point = parse_pipe_points([{"chainage_m": 60.0, "source": "user"}])[0] assert point.as_dict() == {"chainage_m": 60.0, "source": PIPE_SOURCE_USER} # ── 시설 종류 ─────────────────────────────────────────────────────────────── def test_facility_roundtrip(): for facility in ( PIPE_FACILITY_PIPE, PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_FORD_BRIDGE, ): data = {"chainage_m": 100.0, "facility": facility} parsed = parse_pipe_points([data])[0] assert parsed.facility == facility assert parse_pipe_points([parsed.as_dict()])[0].facility == facility def test_unknown_facility_falls_back_to_pipe(): points = parse_pipe_points([{"chainage_m": 100.0, "facility": "bridge"}]) assert points[0].facility == PIPE_FACILITY_PIPE # ── 구간 (기준점 + 시작·종료) ────────────────────────────────────────────── def test_span_roundtrip(): data = {"chainage_m": 100.0, "facility": "box_culvert", "start_m": 92.0, "end_m": 111.0} point = parse_pipe_points([data])[0] assert (point.start_m, point.end_m) == (92.0, 111.0) again = parse_pipe_points([point.as_dict()])[0] assert (again.start_m, again.end_m) == (92.0, 111.0) def test_span_is_normalized_to_contain_anchor(): """시작·종료가 뒤집혔거나 기준점을 안 품으면 정규화한다 (파서는 관대하게).""" flipped = parse_pipe_points([{"chainage_m": 100.0, "start_m": 110.0, "end_m": 90.0}])[0] assert (flipped.start_m, flipped.end_m) == (90.0, 110.0) outside = parse_pipe_points([{"chainage_m": 100.0, "start_m": 104.0, "end_m": 112.0}])[0] assert outside.start_m == 100.0 and outside.end_m == 112.0 def test_half_span_is_completed_with_anchor(): """한쪽만 오면 기준점으로 나머지를 채운다.""" point = parse_pipe_points([{"chainage_m": 100.0, "end_m": 115.0}])[0] assert (point.start_m, point.end_m) == (100.0, 115.0) def test_non_numeric_span_is_dropped(): point = parse_pipe_points([{"chainage_m": 100.0, "start_m": "십", "end_m": None}])[0] assert point.start_m is None and point.end_m is None # ── 시설 옵션 (세월교 관 종류/크기/수량 등) ──────────────────────────────── def test_options_roundtrip(): data = { "chainage_m": 100.0, "facility": "ford_bridge", "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 800, "pipe_count": 3}, } point = parse_pipe_points([data])[0] assert point.options == {"pipe_kind": "흄관", "pipe_diameter_mm": 800, "pipe_count": 3} again = parse_pipe_points([point.as_dict()])[0] assert again.options == point.options def test_pipe_accessory_options_roundtrip(): """배관 부속 키(2026-08-17 재편) — 유형·집수정·기슭막이·돌붙임이 왕복 보존되는지.""" data = { "chainage_m": 240.0, "facility": "pipe", "start_m": 236.0, "end_m": 246.0, "options": { "flow_type": "계곡부형", "pipe_diameter_mm": 1000, "catch_basin": "없음", "revetment": "있음", "revet_length_m": 10.0, "revet_front_m": 4.0, "stone_pitching": "있음", }, } point = parse_pipe_points([data])[0] again = parse_pipe_points([point.as_dict()])[0] assert again.options == data["options"] assert (again.start_m, again.end_m) == (236.0, 246.0) def test_non_dict_options_are_dropped(): point = parse_pipe_points([{"chainage_m": 100.0, "options": "D800x3"}])[0] assert point.options is None def test_empty_options_are_omitted_from_dict(): point = PipePoint(chainage_m=50.0, options={}) assert "options" not in point.as_dict() # ── 시설 속성 승계 — 세부유역 계산 왕복에서 종류가 사라지면 안 된다 ──────── def test_carry_attributes_restores_facility_after_rebuild(): """계산기는 chainage만 다뤄 재구성 목록이 전부 기본 배관이 된다 — 되붙여야 한다.""" requested = parse_pipe_points( [ { "chainage_m": 100.0, "facility": "ford_bridge", "start_m": 95.0, "end_m": 108.0, "options": {"pipe_count": 2}, }, {"chainage_m": 200.0}, ] ) rebuilt = [PipePoint(chainage_m=100.0, source="user"), PipePoint(chainage_m=200.0)] carried = carry_facility_attributes(rebuilt, requested) assert carried[0].facility == PIPE_FACILITY_FORD_BRIDGE assert (carried[0].start_m, carried[0].end_m) == (95.0, 108.0) assert carried[0].options == {"pipe_count": 2} assert carried[1].facility == PIPE_FACILITY_PIPE def test_carry_attributes_uses_nearest_when_chainage_shifted(): """계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준.""" requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}]) rebuilt = [PipePoint(chainage_m=150.37)] carried = carry_facility_attributes(rebuilt, requested) assert carried[0].facility == PIPE_FACILITY_BOX def test_carry_attributes_without_reference_is_noop(): rebuilt = [PipePoint(chainage_m=100.0)] assert carry_facility_attributes(rebuilt, []) is rebuilt assert rebuilt[0].facility == PIPE_FACILITY_PIPE # ── 정렬 유지 ─────────────────────────────────────────────────────────────── def test_points_stay_sorted_by_chainage(): points = parse_pipe_points( [ {"chainage_m": 300.0, "facility": "ford_pavement"}, {"chainage_m": 100.0}, {"chainage_m": 200.0, "facility": "box_culvert"}, ] ) assert [point.chainage_m for point in points] == [100.0, 200.0, 300.0]