Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-31 20:30:56 +09:00
12 changed files with 628 additions and 20 deletions
+10 -3
View File
@@ -35,7 +35,9 @@ def test_d03_parts():
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 16, inline: true })" in ts # 604-3 받기 = 위치 칸 제목 서클
assert (
"createProgressCircle({ size: 16, inline: true })" in ts
) # 604-3 받기 = 위치 칸 제목 서클
site = _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
@@ -52,7 +54,8 @@ def test_d03_parts():
assert "createToolbar(" not in tools and "createIconButton(" not in tools
for act in ("undo", "redo", "view3d", "measure", "stations", "slopes"):
assert f'mapToolButton("{act}"' in tools or f'markButton("{act}"' in tools, act
assert '"rotate-ccw", "rotate-cw"' in tools
table = _src("A00_Common/map/map_toolbar.ts") # 624-2 — 차례 · 보임 = 공용 도구줄 표
assert 'mapToolGroups("d03"' in tools and re.search(r'key: "rotate-ccw".*pages: BOTH', table)
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
@@ -60,7 +63,11 @@ def test_d03_parts():
# 569-2 · 581-1 · 581-2 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억
defs = _src("A00_Common/map/map_layer_list.ts") # 624-2 공용 겹 목록
bar = _src("A00_Common/map/map_layer_bar.ts")
assert "createMapTools({" in bar and "createIconButton({" in bar and "createMapLayerBar({" in layers
assert (
"createMapTools({" in bar
and "createIconButton({" in bar
and "createMapLayerBar({" in layers
)
assert "createToolbar(" not in layers and "createToolbar(" not in bar
assert '"aislo:pref:d03.layers"' in defs and "writeByKey(MAP_LAYER_PAGES[page].pref" in bar
keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs)
+84
View File
@@ -0,0 +1,84 @@
"""PLAN 624-2 — 공용 지도 부품(A00_Common/map) 지킴: 도구줄 표 · 겹 목록 페이지 표 · 범례 · 지형 칩 · D03 은 부르기만.
./venv/Scripts/python.exe -m pytest resources/tester/test_624_2_map_parts.py -q
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MAP = ROOT / "A00_Common" / "map"
D03 = ROOT / "D03_PlanAnalysis"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _toolbar_rows() -> list[list[tuple[str, set[str]]]]:
"""표 → 묶음마다 (열쇠, 보일 페이지)."""
src = _read(MAP / "map_toolbar.ts")
pages = {"BOTH": {"d03", "b05"}, "B05": {"b05"}}
body = src[src.index("MAP_TOOLBAR") : src.index("const isAct")]
groups = re.findall(r"\[\s*(\{.*?\})\s*,?\s*\]", body, re.S)
return [
[(k, pages[p]) for k, p in re.findall(r'key: "([\w-]+)".*?pages: (\w+)', g, re.S)]
for g in groups
]
def test_toolbar_table_d03_same_as_before_plus_terrain() -> None:
d03 = [[k for k, p in g if "d03" in p] for g in _toolbar_rows()]
d03 = [g for g in d03 if g]
# 옛 D03 도구줄 세 묶음 그대로 + 끝에 지형 칩
assert d03 == [
["undo", "redo"],
["view3d", "rotate-ccw", "rotate-cw"],
["stations", "slopes", "measure"],
["terrain"],
], d03
b05 = {k for g in _toolbar_rows() for k, p in g if "b05" in p}
assert {"contour-minor", "footprint", "station-snap", "snap-step", "terrain"} <= b05
tools = _read(ROOT / "ui_template" / "ui_template_map_tools.ts")
for key in {k for g in _toolbar_rows() for k, _ in g} - {"snap-step", "terrain"}:
assert f'"{key}": {{' in tools or f"{key}: {{" in tools, key # 아이콘 · 이름 = MAP_TOOLS
def test_layer_pages() -> None:
src = _read(MAP / "map_layer_list.ts")
assert 'd03: { hide: [], pref: "aislo:pref:d03.layers" }' in src
b05 = re.search(r"b05: \{(.*?)\},", src, re.S).group(1)
assert 'hide: ["bands", "forestRoadFile"]' in b05 # A⑨
assert 'on: ["contour", "stream"]' in b05 # A② 지금 그대로
assert "for (const key of hide) on[key] = false;" in src
def test_common_parts_use_kit_and_no_page_imports() -> None:
for path in MAP.glob("*.ts"):
src = _read(path)
assert len(src.splitlines()) <= 700, path.name
for page in ("D03_PlanAnalysis", "B05_Profile", "B04_PreProcess"):
assert f"../../{page}" not in src and f"../{page}" not in src, (path.name, page)
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, (path.name, raw)
chip = _read(MAP / "map_terrain_chip.ts")
assert "createSegment({" in chip and 'root.dataset.act = "terrain"' in chip
assert "createLegend(row)" in _read(MAP / "map_legend.ts")
assert "createMapTools({" in _read(MAP / "map_layer_bar.ts")
def test_d03_calls_common_only() -> None:
layers = _read(D03 / "D03_PlanAnalysis_UI_Layers.ts")
assert (
'createMapLayerBar({\n page: "d03"' in layers
and 'createMapLegend("d03-plan__legend")' in layers
)
assert len(layers.splitlines()) < 683 # 줄이기만
gl = _read(D03 / "D03_PlanAnalysis_UI_MapGl.ts")
assert "mapLayerSpecs(light, pick)" in gl and "function haloLine" not in gl
defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts")
assert "export const GROUPS" not in defs and "readPref" not in defs
ui_map = _read(D03 / "D03_PlanAnalysis_UI_Map.ts")
assert 'mapToolGroups("d03", {' in ui_map
assert 'createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] })' in ui_map
assert not (D03 / "D03_PlanAnalysis_UI_ForestLayers.ts").exists()
+221
View File
@@ -0,0 +1,221 @@
"""626-2 예정노선 다시 추적 `POST /route/follow` · 고친 예정노선 `expected_route_edit.csv`.
요구 530 D3 · D4 · D8. 못박는 것
- follow = 체인 초기 폴리라인(`_ensure_planned_initial` 파일)과 같은 입력이면 같은 선 · 노드 ·
곡선 · 편집표.
- follow 는 아무것도 안 씀(프로젝트 폴더 그대로) · 경위도 입력 = TM 입력과 같은 선.
- [저장](노선 초안 `expected`) = `expected_route_edit.csv` 씀 · `expected_route.csv` ·
초기 폴리라인 · `initial_snapshot/` 그대로 · 없음 = 그대로 · [] = 지움 ·
GET `/route/plan` `expected_edited`.
- [초기화] = 고친 예정노선 지움(스냅샷에 섞여 있어도).
- follow 길은 `/route/replan` · `as_initial` 을 안 부름.
"""
from __future__ import annotations
import asyncio
import csv
import hashlib
import json
import math
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import pytest
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_RouteFollow as follow
from common_util.common_util_crs import tm_to_lonlat
from common_util.common_util_initial_snapshot import snapshot_dir
from common_util.common_util_route_geometry import (
expected_route_csv_path,
expected_route_edit_path,
planned_route_initial_path,
write_route_csv,
)
ROOT = Path(__file__).resolve().parents[2]
RADIUS_M = 30.0
EPSG = 5186
# 예정노선 점 묶음 꼴(3 m 간격 · 굽은 산길) — 사업지 TM
LINE = [(200000.0 + 3 * i, 450000.0 + 40 * math.sin(i / 20)) for i in range(160)]
def _patch(monkeypatch, root: Path) -> None:
async def paths(_pid):
return root, "stored"
async def criteria(_pid):
return RADIUS_M, 0.0, 0.0
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_plan_criteria", criteria)
monkeypatch.setattr(follow, "_project_paths", paths)
monkeypatch.setattr(follow, "_plan_criteria", criteria)
def _rows(path: Path) -> list[list[float]]:
with path.open(encoding="utf-8") as file:
return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(file)]
def _tree(root: Path) -> dict[str, str]:
return {
str(p.relative_to(root)): hashlib.sha1(p.read_bytes()).hexdigest()
for p in sorted(root.rglob("*"))
if p.is_file()
}
def test_follow_은_체인_초기선과_같은_선(tmp_path: Path) -> None:
write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE])
assert replan._ensure_planned_initial(tmp_path, RADIUS_M)
target = planned_route_initial_path(tmp_path)
table = json.loads(replan._curves_path(target).read_text(encoding="utf-8"))
# 같은 입력 = 체인이 읽는 예정노선 파일 점(csv 4자리)
same = [tuple(p) for p in replan._vertices_of(expected_route_csv_path(tmp_path))]
line = follow.follow_route_line(same, RADIUS_M)
assert line["planned"] == _rows(target)
assert [[round(n["x"], 4), round(n["y"], 4)] for n in line["nodes"]] == _rows(
replan._nodes_path(target)
)
assert line["curves"] == table["curves"] and line["curves"]
assert line["node_edits"] == table.get("edits")
assert line["station_snap"] is True and table["station_snap"] is True
def test_follow_은_안_씀_경위도도_같은_선(tmp_path: Path, monkeypatch) -> None:
_patch(monkeypatch, tmp_path)
(tmp_path / "keep.txt").write_text("x", encoding="utf-8")
before = _tree(tmp_path)
body = follow.RouteFollowRequest(points=LINE)
tm = asyncio.run(follow.follow_route(uuid4(), body))
assert tm["status"] == "success" and len(tm["nodes"]) >= 3
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
async def epsg(*_a):
return EPSG
monkeypatch.setattr(follow, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(follow, "get_surface_crs_epsg", epsg)
lonlat = [tm_to_lonlat(x, y, EPSG) for x, y in LINE]
geo = asyncio.run(
follow.follow_route(uuid4(), follow.RouteFollowRequest(points=lonlat, crs="lonlat"))
)
assert len(geo["nodes"]) == len(tm["nodes"])
assert all(math.dist(a, b) < 0.01 for a, b in zip(geo["planned"], tm["planned"], strict=True))
assert _tree(tmp_path) == before # 저장 0
short = asyncio.run(follow.follow_route(uuid4(), follow.RouteFollowRequest(points=LINE[:1])))
assert short.status_code == 400
def _save(root: Path, expected) -> None:
nodes = [LINE[0], LINE[80], LINE[-1]]
draft = {"nodes": nodes, "curveOn": [True] * 3, "stationSnap": True, "expected": expected}
body = replan.RouteSaveRequest(vertices=[], draft=draft)
out = asyncio.run(replan.replan_route(uuid4(), body))
assert out["status"] == "success"
def test_저장은_고친_예정노선만_씀(tmp_path: Path, monkeypatch) -> None:
_patch(monkeypatch, tmp_path)
async def recompute(*_a, **_k):
return {"route_id": 1}
monkeypatch.setattr(replan, "_recompute", recompute)
write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE])
replan._ensure_planned_initial(tmp_path, RADIUS_M)
(snapshot_dir(tmp_path) / "B05_Profile__route").mkdir(parents=True)
(snapshot_dir(tmp_path) / "B05_Profile__route" / "planned_route.csv").write_text("x")
keep = [expected_route_csv_path(tmp_path), planned_route_initial_path(tmp_path)]
fixed = {p: p.read_bytes() for p in keep}
shot = _tree(snapshot_dir(tmp_path))
edited = [list(p) for p in LINE[::10]]
_save(tmp_path, edited)
assert _rows(expected_route_edit_path(tmp_path)) == [
[round(x, 4), round(y, 4)] for x, y in edited
]
assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == _rows(
expected_route_edit_path(tmp_path)
)
_save(tmp_path, None) # 없음 = 그대로
assert expected_route_edit_path(tmp_path).is_file()
_save(tmp_path, []) # 지움
assert not expected_route_edit_path(tmp_path).exists()
assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == []
assert {p: p.read_bytes() for p in keep} == fixed
assert _tree(snapshot_dir(tmp_path)) == shot # 초기값 그대로
def test_초기화는_고친_예정노선_지움(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Corridor as corridor
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
from common_util import common_util_initial_snapshot as snapshot
class _Cursor:
rowcount = 1
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, *_a):
pass
class _Connection:
def cursor(self, *_a, **_k):
return _Cursor()
async def begin(self):
pass
async def commit(self):
pass
async def rollback(self):
pass
class _Pool:
@asynccontextmanager
async def acquire(self):
yield _Connection()
def _async(value):
async def run(*_a, **_k):
return value
return run
# 추천 [적용]이 고친 예정노선째 스냅샷을 뜬 경우도 — 복원 뒤 지움
stored = snapshot_dir(tmp_path) / "B05_Profile__route"
stored.mkdir(parents=True)
(stored / "expected_route_edit.csv").write_text("sequence,x,y\n", encoding="utf-8")
(snapshot_dir(tmp_path) / "db.json").write_text(json.dumps({"routes": [{"id": 1}]}))
write_route_csv(expected_route_edit_path(tmp_path), [{"x": 0, "y": 0}, {"x": 1, "y": 1}])
monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", _async("p"))
monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(lifecycle, "get_latest_route", _async({"id": 9}))
monkeypatch.setattr(lifecycle, "start_stage", _async(None))
monkeypatch.setattr(snapshot, "restore_initial_snapshot", _async(9))
monkeypatch.setattr(corridor, "prune_corridor_files", lambda *_a: 0)
response = asyncio.run(lifecycle.reset_route_design(uuid4(), None))
assert json.loads(response.body)["restored"] is True
assert not expected_route_edit_path(tmp_path).exists()
@pytest.mark.parametrize("name", ["B05_Profile_Router_RouteFollow.py"])
def test_follow_길은_replan_as_initial_안_부름(name: str) -> None:
text = (ROOT / "B05_Profile" / name).read_text(encoding="utf-8")
code = text.split('"""', 2)[2] # 머리글 뺀 몸통
assert "replan_route" not in code and "as_initial" not in code
assert "expected_route_csv_path" not in code and "write_" not in code