⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt` 가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는 시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음. - `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침** - `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시) - 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q` 옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0 ⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
9.1 KiB
Python
215 lines
9.1 KiB
Python
"""계획노선 shapefile 입력 + PRJ 2개 분리 검증 (2026-08-31).
|
|
|
|
원청 실물 자료(일월.용화.2.2 — UTM-K 노선 shapefile, 동부원점 지형 PRJ/TFW)를
|
|
그대로 써서 판독·좌표계 선택·필수 세트 판정을 확인한다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 B03_FileInput.B03_FileInput_Engine import reconcile_shapefile_members # noqa: E402
|
|
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402
|
|
from B03_FileInput.B03_FileInput_Engine_Shapefile import ( # noqa: E402
|
|
read_shapefile_attributes,
|
|
read_shapefile_parts,
|
|
shapefile_crs_input,
|
|
shapefile_encoding,
|
|
shapefile_epsg_label,
|
|
)
|
|
from B03_FileInput.B03_FileInput_Repository_Temp import ( # noqa: E402
|
|
is_batch_required_complete,
|
|
)
|
|
from B03_FileInput.B03_FileInput_Router import _missing_required_file_types # noqa: E402
|
|
from common_util.common_util_crs import find_project_prj # noqa: E402
|
|
from common_util.common_util_route_geometry import ( # noqa: E402
|
|
find_planned_route_file,
|
|
read_planned_route,
|
|
)
|
|
|
|
# 업로드 원본을 그대로 둔 시험용 세트 (basename만 route/terrain으로 맞췄다).
|
|
FIXTURE_DIR = Path(
|
|
r"C:\Users\umsan\AppData\Local\Temp\claude"
|
|
r"\d--02-Software-Prog---------------------"
|
|
r"\72ca32d1-3b09-48f8-90f8-b4c851bf490e\scratchpad"
|
|
)
|
|
ROUTE_SHP = FIXTURE_DIR / "shpset" / "route.shp"
|
|
TERRAIN_PRJ = FIXTURE_DIR / "terrain.prj"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not ROUTE_SHP.exists(), reason="원청 실물 shapefile 세트가 없는 환경"
|
|
)
|
|
|
|
|
|
def test_shapefile_geometry_matches_source() -> None:
|
|
"""실물 노선: PolyLine 1파트·121정점, 2D(z=0)."""
|
|
parts = read_shapefile_parts(ROUTE_SHP)
|
|
assert len(parts) == 1
|
|
assert len(parts[0]) == 121
|
|
assert all(point[2] == 0.0 for point in parts[0])
|
|
|
|
|
|
def test_cpg_949_decodes_korean_attributes() -> None:
|
|
"""`.cpg` 내용이 `949` 한 줄이어도 CP949로 읽어 한글 속성이 살아야 한다."""
|
|
assert shapefile_encoding(ROUTE_SHP) == "cp949"
|
|
attributes = read_shapefile_attributes(ROUTE_SHP)
|
|
assert attributes["대상지"] == "일월.용화.2.2"
|
|
assert attributes["길이"] == "2.200"
|
|
|
|
|
|
def test_crs_input_is_wkt_not_epsg_code() -> None:
|
|
"""노선 PRJ는 `to_epsg()`가 None이다 — 변환 입력은 원문 WKT로 나와야 한다."""
|
|
crs_input = shapefile_crs_input(ROUTE_SHP)
|
|
assert crs_input is not None
|
|
assert crs_input.startswith("PROJCS[")
|
|
assert "PCS_ITRF2000_UTM_K" in crs_input
|
|
# 라벨은 별도로 붙는다(표시·로그용).
|
|
assert shapefile_epsg_label(ROUTE_SHP) == 5179
|
|
|
|
|
|
def test_route_reprojects_into_terrain_raster_extent() -> None:
|
|
"""노선(UTM-K)을 지형 계로 옮기면 TFW 원점 안쪽에 앉아야 한다."""
|
|
from pyproj import Transformer
|
|
|
|
from common_util.common_util_crs import crs_input_from_prj
|
|
|
|
planned = read_planned_route(ROUTE_SHP)
|
|
assert planned is not None
|
|
target = crs_input_from_prj(TERRAIN_PRJ.read_text(encoding="utf-8"))
|
|
transformer = Transformer.from_crs(planned.crs_input, target, always_xy=True)
|
|
moved = [transformer.transform(vertex.x, vertex.y) for vertex in planned.vertices]
|
|
|
|
xs = [point[0] for point in moved]
|
|
ys = [point[1] for point in moved]
|
|
# TFW: 화소 0.2m, 좌상단 원점 (208288.8376, 368092.2820), y는 아래로 진행.
|
|
origin_x, origin_y = 208288.8376409571, 368092.2819918637
|
|
assert min(xs) > origin_x
|
|
assert max(ys) < origin_y
|
|
# 노선 연장은 도형 기준 약 2.1km (속성 길이 2.2km와 같은 자릿수).
|
|
assert 2000.0 < planned.vertices[-1].chainage_m < 2300.0
|
|
|
|
|
|
def test_planned_route_reader_prefers_shapefile(tmp_path: Path) -> None:
|
|
"""노선 파일이 CSV·shapefile 둘 다 있으면 shapefile을 고른다."""
|
|
input_dir = tmp_path / "input"
|
|
(input_dir / "shp").mkdir(parents=True)
|
|
(input_dir / "csv").mkdir(parents=True)
|
|
for extension in (".shp", ".shx", ".dbf", ".cpg", ".prj"):
|
|
target = input_dir / "shp" / f"route{extension}"
|
|
target.write_bytes(ROUTE_SHP.with_suffix(extension).read_bytes())
|
|
(input_dir / "csv" / "old.csv").write_text(
|
|
"x,y,z,crs_epsg\n1,2,3,5186\n4,5,6,5186\n", encoding="utf-8"
|
|
)
|
|
|
|
found = find_planned_route_file(input_dir)
|
|
assert found is not None and found.suffix == ".shp"
|
|
planned = read_planned_route(found)
|
|
assert planned is not None
|
|
assert len(planned.vertices) == 121
|
|
assert planned.name == "일월.용화.2.2"
|
|
|
|
|
|
def test_terrain_prj_wins_over_route_prj(tmp_path: Path) -> None:
|
|
"""PRJ가 둘이면 지형(input/prj/)을 프로젝트 좌표계로 고른다."""
|
|
stage_input = tmp_path / "B03_FileInput" / "input"
|
|
(stage_input / "shp").mkdir(parents=True)
|
|
(stage_input / "prj").mkdir(parents=True)
|
|
# 노선 PRJ 파일명이 정렬상 앞서도(a < z) 지형이 이겨야 한다.
|
|
(stage_input / "shp" / "a_route.prj").write_text(
|
|
ROUTE_SHP.with_suffix(".prj").read_text(encoding="utf-8"), encoding="utf-8"
|
|
)
|
|
(stage_input / "prj" / "z_terrain.prj").write_text(
|
|
TERRAIN_PRJ.read_text(encoding="utf-8"), encoding="utf-8"
|
|
)
|
|
|
|
chosen = find_project_prj(tmp_path)
|
|
assert chosen is not None
|
|
assert chosen.name == "z_terrain.prj"
|
|
assert "Modified East Belt" in chosen.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_terrain_prj_pairs_with_terrain_data(tmp_path: Path) -> None:
|
|
"""`input/prj/`에 옛 PRJ가 남아 있어도 지금 지형 자료와 짝인 것을 고른다.
|
|
|
|
실측 사고(2026-08-31): 옛 `result.prj`(East Belt 2010, FN 600,000)가 이름 정렬에서
|
|
새 `용화.prj`(Modified East Belt, FN 500,000)보다 앞서 뽑혀 노선이 Y로 100,000m
|
|
어긋났고 B05 경로 계산이 400으로 실패했다.
|
|
"""
|
|
stage_input = tmp_path / "B03_FileInput" / "input"
|
|
for name in ("prj", "las", "tfw"):
|
|
(stage_input / name).mkdir(parents=True)
|
|
# 옛 잔재 — 이름이 앞서고, 짝이 되는 지형 자료는 이제 없다.
|
|
(stage_input / "prj" / "result.prj").write_text("stale", encoding="utf-8")
|
|
# 이번 지형 — LAS/TFW와 basename이 같다.
|
|
(stage_input / "prj" / "용화.prj").write_text(
|
|
TERRAIN_PRJ.read_text(encoding="utf-8"), encoding="utf-8"
|
|
)
|
|
(stage_input / "las" / "용화.las").write_bytes(b"LASF")
|
|
(stage_input / "tfw" / "용화.tfw").write_text("0.2 0 0 -0.2 1 2", encoding="utf-8")
|
|
|
|
chosen = find_project_prj(tmp_path)
|
|
assert chosen is not None
|
|
assert chosen.name == "용화.prj"
|
|
assert "Modified East Belt" in chosen.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_reconcile_pulls_prj_that_arrived_first(tmp_path: Path) -> None:
|
|
"""PRJ가 SHP보다 먼저 도착해 input/prj/에 앉아도 세트 폴더로 끌어온다."""
|
|
stage_input = tmp_path / "input"
|
|
(stage_input / "shp").mkdir(parents=True)
|
|
(stage_input / "prj").mkdir(parents=True)
|
|
(stage_input / "shp" / "route.shp").write_bytes(ROUTE_SHP.read_bytes())
|
|
(stage_input / "prj" / "route.prj").write_text("dummy", encoding="utf-8")
|
|
(stage_input / "prj" / "terrain.prj").write_text("keep", encoding="utf-8")
|
|
|
|
moved = reconcile_shapefile_members(tmp_path)
|
|
|
|
assert [path.name for path in moved] == ["route.prj"]
|
|
assert (stage_input / "shp" / "route.prj").exists()
|
|
assert not (stage_input / "prj" / "route.prj").exists()
|
|
assert (stage_input / "prj" / "terrain.prj").exists() # 지형 PRJ는 그대로
|
|
|
|
|
|
def test_analyze_dispatches_shapefile() -> None:
|
|
"""업로드 분석기가 .shp를 계획노선으로 인식한다."""
|
|
metadata = analyze_input_metadata(ROUTE_SHP)
|
|
assert metadata["purpose"] == "planned_route"
|
|
assert metadata["shape_type"] == "PolyLine"
|
|
assert metadata["point_count"] == 121
|
|
assert metadata["epsg"] == 5179
|
|
assert metadata["missing_members"] == []
|
|
|
|
|
|
def test_required_set_accepts_shapefile_instead_of_csv() -> None:
|
|
"""필수 세트: 노선은 csv|shp 택일, shp면 형제 파일까지 본다."""
|
|
complete = {"shp", "shx", "dbf", "route_prj", "prj", "tfw", "las"}
|
|
assert _missing_required_file_types(complete) == []
|
|
|
|
# 형제 파일이 빠지면 막는다 — 노선 PRJ(route_prj)도 형제다.
|
|
assert _missing_required_file_types({"shp", "prj", "tfw", "las"}) == [
|
|
"dbf",
|
|
"route_prj",
|
|
"shx",
|
|
]
|
|
|
|
# 노선 PRJ만 있고 지형 PRJ가 없으면 프로젝트 좌표계를 못 정한다.
|
|
assert "prj" in _missing_required_file_types({"shp", "shx", "dbf", "route_prj", "tfw", "las"})
|
|
|
|
# 보관함 묶음은 확장자만 알아 노선/지형 PRJ를 못 가린다 — shx·dbf까지만 본다.
|
|
assert is_batch_required_complete({"shp", "shx", "dbf", "prj", "tfw", "las"})
|
|
assert not is_batch_required_complete({"shp", "prj", "tfw", "las"})
|
|
|
|
# 노선이 아예 없으면 막는다.
|
|
assert "csv/shp" in _missing_required_file_types({"prj", "tfw", "las"})
|
|
|
|
# 기존 CSV 흐름은 그대로 통과한다(회귀).
|
|
assert _missing_required_file_types({"csv", "prj", "tfw", "laz"}) == []
|
|
assert is_batch_required_complete({"csv", "prj", "tfw", "laz"})
|