feat(D 예정노선): 올린 임도망 묶음 쌓기 · 목록(고르기 · 지움) · 지도 밝은 분홍 + 검은 테 (PLAN 605-1 · 605-2 · 요구 476-1)

- 서버 — 올릴 때마다 묶음 하나(`D03_PlanAnalysis/임도망도/<번호>`) · 옛 한 파일 꼴 = 묶음 legacy · 합친 값 + 묶음 줄 · 목록 GET · 묶음 DELETE · 20 묶음까지
- 화면 — 위치 칸 목록(묶음 줄 · 펼치면 선 줄 · 고름 = 지도 강조 + 맞춤 · 지움 = 물음 뒤) · 색 주황 → 밝은 분홍(`--draw-map-forest-road-file`)
- 시험 584_3 — 묶음 쌓기 · 지움 · legacy · 20 묶음 · 화면 정의

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 21:49:20 +09:00
co-authored by Claude Sonnet 5.5
parent 20ed22709c
commit 40cecf27a2
12 changed files with 629 additions and 53 deletions
@@ -62,8 +62,8 @@ def test_save_roads_files_clip_and_saved(tmp_path) -> None:
assert got["crs"] == ["EPSG:5179"]
assert got["collection"]["features"][0]["properties"]["임도명"] == "임도0" # cp949 dbf
assert got["source"]["files"] == [name for name, _ in files]
assert (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).is_file()
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
assert len(list((tmp_path / fr.FOLDER / fr.BUNDLE_DIR).glob("*.zip"))) == 1
assert not list((tmp_path / fr.FOLDER).rglob("*.new"))
assert fr.road_layer(tmp_path, BOX, EPSG) == json.loads(json.dumps(got)) # 다시 열면 저장본
@@ -77,7 +77,8 @@ def test_zip_in_folder_and_missing_prj_is_5179(tmp_path) -> None:
def test_bad_uploads_keep_old(tmp_path) -> None:
files = _shp_files(tmp_path / "shp", LINES)
first = fr.save_roads(tmp_path, files, BOX, EPSG)
stamp = (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).stat().st_mtime_ns
folder = tmp_path / fr.FOLDER / fr.BUNDLE_DIR
before = sorted(p.name for p in folder.iterdir())
bad = [
([("a.zip", b"not a zip")], "zip 파일을 열지"),
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"),
@@ -87,9 +88,8 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
for upload, text in bad:
with pytest.raises(ValueError, match=re.escape(text)):
fr.save_roads(tmp_path, upload, BOX, EPSG)
assert (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).stat().st_mtime_ns == stamp # 옛 것 그대로
assert sorted(p.name for p in folder.iterdir()) == before # 쌓인 것 그대로
assert fr.road_layer(tmp_path, BOX, EPSG)["count"] == first["count"]
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
def test_clip_box_site_5km_and_distance(tmp_path) -> None:
@@ -112,6 +112,67 @@ def test_clip_box_site_5km_and_distance(tmp_path) -> None:
fr.save_roads(tmp_path, far, frame, EPSG, site)
def test_bundles_stack_and_delete(tmp_path) -> None:
"""605-1 — 올릴 때마다 묶음 · 합친 값 + 묶음 줄 · 지움 = 그 묶음만 · 옛 한 파일 꼴 = legacy."""
files = _shp_files(tmp_path / "shp", LINES)
one = fr.save_roads(tmp_path, files, BOX, EPSG)
two = fr.save_roads(tmp_path, [("copy.zip", _zip(dict(files)))], BOX, EPSG)
assert [b["name"] for b in two["bundles"]] == [
"47250",
"copy.zip",
] # 낱 파일 = 첫 파일 이름 줄기
assert two["count"] == 2 * one["count"] and two["total"] == 6
assert two["length_m"] == pytest.approx(2 * one["length_m"], abs=0.2)
assert {f["properties"]["__bundle"] for f in two["collection"]["features"]} == {
b["id"] for b in two["bundles"]
}
assert all(b["count"] == 2 and b["length_m"] > 1200 for b in two["bundles"])
for b in two["bundles"]: # 묶음 안 선 차례
mine = [
f["properties"]
for f in two["collection"]["features"]
if f["properties"]["__bundle"] == b["id"]
]
assert [p["__line"] for p in mine] == [0, 1]
gone = two["bundles"][0]["id"]
fr.delete_bundle(tmp_path, gone)
rest = fr.road_layer(tmp_path, BOX, EPSG)
assert [b["id"] for b in rest["bundles"]] == [two["bundles"][1]["id"]]
assert rest["count"] == one["count"]
for bad in (gone, "../x", "zz"):
with pytest.raises(FileNotFoundError):
fr.delete_bundle(tmp_path, bad)
fr.delete_bundle(tmp_path, rest["bundles"][0]["id"])
with pytest.raises(FileNotFoundError):
fr.road_layer(tmp_path, BOX, EPSG)
def test_legacy_single_file_reads_as_one_bundle(tmp_path) -> None:
folder = tmp_path / fr.FOLDER
folder.mkdir()
(folder / fr.SOURCE_ZIP).write_bytes(fr.pack(_shp_files(tmp_path / "shp", LINES)))
(folder / fr.SOURCE_META).write_text(
json.dumps({"files": ["옛.zip"], "bytes": 1, "uploaded_at": "2026-01-01T00:00:00Z"}),
"utf-8",
)
got = fr.road_layer(tmp_path, BOX, EPSG)
assert [(b["id"], b["name"]) for b in got["bundles"]] == [(fr.LEGACY, "옛.zip")]
assert got["count"] == 2
new = fr.save_roads(tmp_path, _shp_files(tmp_path / "n", LINES), BOX, EPSG)
assert [b["id"] for b in new["bundles"]][0] == fr.LEGACY and len(new["bundles"]) == 2
fr.delete_bundle(tmp_path, fr.LEGACY)
assert not (folder / fr.SOURCE_ZIP).exists() and not (folder / fr.SOURCE_META).exists()
assert len(fr.road_layer(tmp_path, BOX, EPSG)["bundles"]) == 1
def test_bundle_limit(tmp_path) -> None:
files = _shp_files(tmp_path / "shp", LINES)
for _ in range(fr.MAX_BUNDLES):
fr.save_roads(tmp_path, files, BOX, EPSG)
with pytest.raises(ValueError, match="하나를 지우고"):
fr.save_roads(tmp_path, files, BOX, EPSG)
def test_no_upload_is_not_found(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
fr.road_layer(tmp_path, BOX, EPSG)
@@ -121,6 +182,9 @@ def test_upload_limit() -> None:
router = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_Router.py").read_text("utf-8")
assert fr.MAX_BYTES == 100 * 1024 * 1024
assert "status_code=413" in router and "forest_road.MAX_BYTES" in router
assert (
"forest-roads/bundles" in router and "forest-roads/{bundle}" in router
) # 605-1 목록 · 지움
# ── 산사태취약지역 표 ──
@@ -281,6 +345,18 @@ def test_screen_defs() -> None:
assert "uploadRoads" in page and "uploadPlanForestRoads(" in page
assert "<input" not in page and 'createElement("input")' not in page
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
# 605-2 — 밝은 분홍 · 목록(부품 상자만) · 고름 = 강조(GL 거름 · 캔버스) · 지움 = 물음 뒤
assert re.search(r"--draw-map-forest-road-file: #ff2fd0;", theme)
mapgl = (folder / "D03_PlanAnalysis_UI_MapGl.ts").read_text("utf-8")
roads = (folder / "D03_PlanAnalysis_UI_RoadList.ts").read_text("utf-8")
assert "forestRoadFile-pick-halo" in mapgl and "setRoadPick" in mapgl
assert "roadPick" in paint and "deletePlanForestRoad(" in page and "pickRoad" in page
assert "askConfirm(" in roads and "danger: true" in roads and "createListRow" in roads
assert (
"<button" not in roads and "<input" not in roads and 'createElement("button")' not in roads
)
assert "createRoadList(" in site and "roads.reload()" in site
assert len((folder / "D03_PlanAnalysis_UI_RoadList.ts").read_text("utf-8").splitlines()) <= 700
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
assert "이 시도 자료 없음" in legend
for name in (