"""110 — B08 수리 판정 하상경사 ← B06 횡단 경사. 관 = 관 바닥 두 끝 경사(`CulvertLayout.pipe.slopePct`) · 박스 = 박스 두 끝 원지반 경사(본체는 늘 수평) · 관 길이와 같은 길(`STRUCTURE_ROW_KEYS` → 서버 Node → `_AREA_KEYS` → 정본) · B08 은 연결값(linked)으로 실음(저장된 측점 null 이 덮지 않게) · 0 % 이하는 빈칸(손 입력) · 세월교 · 물넘이는 장 기본값 그대로. """ from __future__ import annotations import copy import json import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402 from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402 DEFAULTS, SHEET_DEFAULTS, station_rows, ) from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 FIXTURES = Path(__file__).parent / "fixtures" NODE_MODULES = ROOT / "config" / "node_modules" needs_node = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") BASINS = [ {"kind": "detail_basin", "chainage_m": c, "area_m2": 10717.0, "flow_length_m": 163.9, "relief_m": 120.0, "design_flow_m3s": 1.0, "tc_minutes": 5.0} for c in (85.05, 173.09, 300.0, 400.0, 500.0) ] # fmt: skip POINTS = [ PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000"}), PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}), PipePoint(chainage_m=300.004, facility="box_culvert", options={"body_width_m": 2.0, "body_height_m": 1.5}), PipePoint(chainage_m=400.0, options={"pipe_diameter_mm": 800}), PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}), ] # fmt: skip DESIGNS = [ # 관 자리 85.052 ↔ 횡단 85.0(스냅 · 0.5 m 안) {"chainage_m": 85.0, "design": {"pipe_slope_pct": 5.0, "pipe_length_m": 9}}, # 세월교에 경사 키가 있어도 안 이음(장 기본값) {"chainage_m": 173.09, "design": {"pipe_slope_pct": 4.0, "box_ground_slope_pct": 4.0}}, {"chainage_m": 300.0, "design": {"box_ground_slope_pct": 2.0}}, {"chainage_m": 400.0, "design": {"pipe_slope_pct": 0.0}}, # 수평 관 → 빈칸 {"chainage_m": 501.0, "design": {"pipe_slope_pct": 3.0}}, # 1 m 떨어짐 → 짝 없음 ] def _rows() -> dict[float, dict]: return {r["chainage_m"]: r for r in station_rows(POINTS, BASINS, DESIGNS)} def test_관_경사가_하상경사_칸으로() -> None: assert _rows()[85.052]["linked"]["slope_denominator"] == 20.0 # 5 % → 1/20 def test_박스는_원지반_경사() -> None: assert _rows()[300.004]["linked"]["slope_denominator"] == 50.0 # 2 % → 1/50 def test_0_퍼센트_이하와_짝_없음은_빈칸() -> None: rows = _rows() assert "slope_denominator" not in rows[400.0]["linked"] assert "slope_denominator" not in rows[500.0]["linked"] assert DEFAULTS["slope_denominator"] is None # 손 입력 자리 def test_세월교는_장_기본값_그대로() -> None: assert "slope_denominator" not in _rows()[173.09]["linked"] assert SHEET_DEFAULTS["ford"]["slope_denominator"] > 0 def test_연결값으로_판정이_나옴() -> None: row = _rows()[85.052] out = compute({**DEFAULTS, **row["linked"], "r24_mm": 231.56, "sheet": "pipe"}) assert out["ok"] in (True, False) assert out["slope_bed"] == pytest.approx(0.05) def test_저장된_측점_null_이_연결값을_덮지_않음() -> None: """화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜.""" ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ( ROOT / "B08_Quantity" / "B08_Quantity_Hydro_Table.ts" ).read_text(encoding="utf-8") assert "v != null || !(k in linkedOf(station))" in ui assert "...station.linked," in ui def test_패치가_받는다() -> None: patch = CrossSectionPatch(chainage_m=1, pipe_slope_pct=0.0, box_ground_slope_pct=-3.5) assert (patch.pipe_slope_pct, patch.box_ground_slope_pct) == (0.0, -3.5) def _server_rows(sections: list[dict]) -> dict[float, dict]: from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE from common_util.common_util_node_bundle import run_bundle_json output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) assert output, "서버 Node 번들 실행 실패" return {round(r["chainage_m"], 3): r for r in output["areas"]} @needs_node def test_서버가_관_경사를_관_길이와_같이_냄() -> None: sections = json.loads((FIXTURES / "b07_cross_overlay_sections.json").read_text("utf-8")) row = _server_rows(sections["cross_sections"])[85.052] assert row["pipe_length_m"] > 0 # 관 바닥 두 끝 경사 — 유입이 유출보다 낮게 그려지지 않음(역경사는 수평으로 클램프) assert row["pipe_slope_pct"] >= 0 @needs_node def test_서버가_박스_원지반_경사를_주인_측점에만() -> None: data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8")) sections = data["cross_sections"] box_at = next(s for s in sections if s.get("box")) # 옆 측점에 같은 박스가 걸쳐도(폭 안 연동) 주인 측점에만 실림 neighbour = next(s for s in sections if s["chainage_m"] == 340) neighbour["box"] = copy.deepcopy(box_at["box"]) rows = _server_rows(sections) assert isinstance(rows[352.14]["box_ground_slope_pct"], float) assert "box_ground_slope_pct" not in rows[340] # 세월교 · 박스 흙막이는 관이 아님 — 관 경사 안 실음 assert all("pipe_slope_pct" not in r for r in rows.values())