"""D 노선분석 GIS 겹 더함 2(PLAN 584-1) — 단층 · 지질구조선 잘라 가닥 · 길이 · 실패 = 오류 · 덮으면 다시 안 받음 · 산사태위험지도(키 없음 · 그림 아님 = 오류 · 옮겨 그림 · 덮으면 저장본) · 0차곡 흐름누적(웅덩이 · 평지 넘김) · 임계 · 선 뽑기 · 저장본 · 화면 겹 정의. ./venv/Scripts/python.exe -m pytest resources/tester/test_584_1_plan_geo_lines.py -q """ import base64 import io import json import math import re from pathlib import Path import numpy as np import pytest from common_util.common_util_crs import tm_to_lonlat from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zo EPSG = 5186 BOX = (230000.0, 450000.0, 232000.0, 451000.0) # 2 × 1 km ROOT = Path(__file__).resolve().parents[2] def _line(gid: str, points: list[tuple[float, float]]) -> dict: coords = [list(tm_to_lonlat(x, y, EPSG)) for x, y in points] return {"id": gid, "geometry": {"type": "LineString", "coordinates": coords}, "properties": {}} # ── 선 겹 ── def test_clip_lines_length() -> None: found = [ _line("a", [(229000.0, 450500.0), (231000.0, 450500.0)]), # 반만 안 = 1,000 m _line("a", [(229000.0, 450500.0), (231000.0, 450500.0)]), # 겹침 _line("b", [(231500.0, 450100.0), (231500.0, 450400.0)]), # 300 m _line("c", [(240000.0, 460000.0), (240100.0, 460000.0)]), # 밖 ] features, summary = layers.clip_lines(found, BOX, EPSG) assert summary["count"] == 2 == len(features) assert summary["length_m"] == pytest.approx(1300.0, rel=0.005) assert features[0]["properties"]["length_m"] == pytest.approx(1000.0, rel=0.005) class FakeWfs: def __init__(self, fail: bool = False) -> None: self.fail, self.calls = fail, [] def __call__(self, typename, filename, bounds, output_dir) -> None: self.calls.append(typename) if self.fail: return feats = [_line("a", [(230100.0, 450100.0), (230100.0, 450600.0)])] body = {"type": "FeatureCollection", "features": feats} (Path(output_dir) / filename).write_text(json.dumps(body), "utf-8") @pytest.mark.parametrize("kind", list(layers.LINE_KINDS)) def test_lines_fetch_once(tmp_path, monkeypatch, kind) -> None: fake = FakeWfs() monkeypatch.setattr(layers, "request_vworld_wfs", fake) label, typename = layers.LINE_KINDS[kind] assert layers.ensure_lines(tmp_path, BOX, EPSG, kind)["fetched"] and fake.calls == [typename] saved = json.loads((tmp_path / f"{label}_bounds.geojson").read_text("utf-8")) assert saved["summary"]["count"] == 1 assert saved["summary"]["length_m"] == pytest.approx(500.0, rel=0.005) inner = (230050.0, 450050.0, 231000.0, 450900.0) assert not layers.ensure_lines(tmp_path, inner, EPSG, kind)["fetched"] # 덮으면 다시 안 받음 assert layers.ensure_lines(tmp_path, (229000.0, *BOX[1:]), EPSG, kind)["fetched"] def test_lines_fail_raises(tmp_path, monkeypatch) -> None: monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=True)) with pytest.raises(RuntimeError): layers.ensure_lines(tmp_path, BOX, EPSG, "faults") assert not list(tmp_path.iterdir()) def test_line_kinds_vworld_names() -> None: assert layers.LINE_KINDS["faults"][1] == "lt_l_gimsfault" assert layers.LINE_KINDS["lineaments"][1] == "lt_l_gimslinea" # ── 산사태위험지도 ── def _png(width: int, height: int, rgba: tuple[int, int, int, int]) -> bytes: from PIL import Image buffer = io.BytesIO() Image.new("RGBA", (width, height), rgba).save(buffer, format="PNG") return buffer.getvalue() def test_landslide_no_key(tmp_path, monkeypatch) -> None: monkeypatch.setenv(landslide.LANDSLIDE_KEY_ENV, " ") with pytest.raises(landslide.MissingKeyError, match="키 없음"): landslide.ensure_landslide(tmp_path, BOX, EPSG) assert not list(tmp_path.iterdir()) def test_landslide_not_image(tmp_path, monkeypatch) -> None: class Reply(io.BytesIO): def __enter__(self): return self def __exit__(self, *exc) -> None: pass monkeypatch.setenv(landslide.LANDSLIDE_KEY_ENV, "k") monkeypatch.setattr(landslide.urllib.request, "urlopen", lambda *a, **k: Reply(b"")) with pytest.raises(RuntimeError, match="그림이 아닌"): landslide.ensure_landslide(tmp_path, BOX, EPSG) assert not list(tmp_path.iterdir()) def test_landslide_warp_and_saved(tmp_path, monkeypatch) -> None: from PIL import Image calls = [] def fake(bounds, width, height): calls.append((width, height)) return _png(width, height, (220, 38, 38, 200)) monkeypatch.setattr(landslide, "request_wms", fake) value = landslide.ensure_landslide(tmp_path, BOX, EPSG) assert calls == [(1000, 500)] # 2 m/px assert value["box_m"] == list(BOX) raw = base64.b64decode(value["image"].split(",", 1)[1]) image = np.asarray(Image.open(io.BytesIO(raw))) assert image.shape == (500, 1000, 4) assert tuple(image[250, 500]) == (220, 38, 38, 200) # 가운데 = 받은 색 inner = (230100.0, 450100.0, 231000.0, 450900.0) assert landslide.ensure_landslide(tmp_path, inner, EPSG) == value and len(calls) == 1 # ── 0차곡 ── def _valley(n: int = 60) -> np.ndarray: """가운데(열 n/2)로 모이고 북(행 끝)에서 남으로 내려가는 V 골짜기 — 행 = 남 → 북.""" r, c = np.mgrid[0:n, 0:n].astype(float) return 0.5 * r + 0.8 * np.abs(c - n // 2) def test_accumulation_valley_floor() -> None: z = _valley() acc = zo.d8_accumulation(z, np.ones(z.shape, bool)) assert acc[0, 30] == acc.max() # 골 바닥 남쪽 끝이 가장 큼 assert acc[0, 30] > 0.4 * z.size # 반쯤 넘게 모임 def test_accumulation_crosses_pit_and_flat() -> None: z = _valley() z[30, 30] -= 5.0 # 웅덩이 z[20:24, 20:41] = z[22, 30] # 평지 띠(등고선 칸) acc = zo.d8_accumulation(z, np.ones(z.shape, bool)) assert acc[0, 30] > 0.4 * z.size # 웅덩이 · 평지에서 안 끊김 def test_zero_order_mask_threshold() -> None: acc = np.array([[10.0, 249.0, 250.0, 2499.0, 2500.0]]) mask = zo.zero_order_mask(acc, np.ones(acc.shape, bool), 2.0, 1000.0, 10000.0) # 칸 4 m² assert mask.tolist() == [[False, False, True, True, False]] def test_zero_order_lines_head_only() -> None: n = 60 z = _valley(n) x = y = np.arange(n) * 2.0 lines = zo.zero_order_lines(z, np.ones(z.shape, bool), x, y, 2.0, min_m2=400.0, max_m2=2000.0) assert lines # 골 바닥(x = 60 m) 위 · 집수 400 ~ 2,000 m² 사이만(골 위쪽 머리) floor = sorted(p[1] for line in lines for p in line if abs(p[0] - 60.0) < 1e-6) assert floor and floor[0] > 0.0 and floor[-1] < y[-1] # 남 끝(1차 계류) · 북 끝 빠짐 for line in lines: assert sum(math.dist(a, b) for a, b in zip(line, line[1:])) >= zo.ZERO_ORDER_MIN_LEN_M def test_zero_order_no_dem(tmp_path) -> None: with pytest.raises(FileNotFoundError): zo.zero_order_layer(tmp_path, tmp_path, EPSG) # ── 화면 ── def test_screen_defs_icons_colors() -> None: defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") for key, color in ( ("faults", "fault"), ("lineaments", "lineament"), ("zeroOrder", "zero-order"), ): # 634-2 표 한 줄 = row(열쇠, 아이콘, 호버제목, 묶음[, true]) · true 없음 = 처음 끔 entry = re.search(rf'row\("{key}", "(\w+)", "[^"]+", (?:"\w+"|null)(, true)?\)', defs) assert entry and not entry.group(2), key # 처음 값 끔 assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in defs for kind in layers.LINE_KINDS: assert f'kind: "{kind}"' in defs # 서버 열쇠와 같음 page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text( "utf-8" ) # 586-2 assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582)