feat(509-5 · 509-2 B05 추천 지형): 분석 지형 고르기 = 추천 노선 창 안 · 지형마다 추천 미리 셈 · [적용] = 창 지형이 설계 지형 · 평면은 지형 이름만 · 설계 지형 격자 하나(수치지형도 적용이면 그 격자)

- 전처리 미리 셈이 고를 수 있는 지형마다(설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만) 다섯 갈래 + 초기 종단
- 창 지형 바꿈 = 그 지형 저장본만 · 없으면 409 안내(+[재계산]) · 기본값 판정에서 지형 뺌
- POST /route/initial terrain = 창 지형 · [적용] draft.terrain = 창 지형 · 손 [저장]은 지형 안 바꿈(서버도 as_initial 일 때만)
- 평면 분석 지형 고르기 · 화면 취향(routeedit-terrain) 걷음 · 지반고 · 묶음 초안 = 설계 지형(null) · 적용 뒤 받아 둔 격자 버림
- 시험 test_509_5 · test_509_2 · 걷은 고르기를 보던 옛 시험 새 동작으로(352_3 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:41:20 +09:00
co-authored by Claude Opus 5.5
parent 23fd2f6f8d
commit 4eec3a4b37
30 changed files with 441 additions and 169 deletions
@@ -50,7 +50,6 @@ const local = { grid, design: "las" };
let remoteCalls = 0;
STUBS["B05_Profile_Api_Replan"] = {
analysisTerrain: () => "las",
fetchRoutePlan: async () => ({ planned: job.line, curves: [], min_radius_m: job.minRadius }),
};
STUBS["B05_Profile_Api_Structures"] = {
+11 -2
View File
@@ -54,6 +54,10 @@ const STUBS = {
"./B05_Profile_Api_Fetch": {
clearRouteLatestCache: () => calls.push("latest:clear"),
},
// 509-2 — 추천 [적용] 뒤 받아 둔 설계 지형 격자 버림
"./B05_Profile_Api_TerrainGrid": {
forgetDesignTerrainGrid: () => calls.push("grid:forget"),
},
"./B05_Profile_Api_Replan": {
replanRoute: async (_p, body) => {
bodies.push(JSON.parse(JSON.stringify(body)));
@@ -115,7 +119,6 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
curveRadius: [null, 25],
}),
changed: () => changed,
terrain: () => "sheet",
snap: () => ({ stationSnap: true, stationSnapStep: 20 }), // 390-5 — 켬끔 · 간격 한 벌
initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) },
onApplied: () => (rebuilt += 1),
@@ -158,7 +161,13 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
curveOn: [true, true],
curveRadius: [null, null],
};
out.recommendOk = await apply.saveRecommendedRoute("p1", snapshot, { mode: "earthwork" }, true);
out.recommendOk = await apply.saveRecommendedRoute(
"p1",
snapshot,
{ mode: "earthwork" },
true,
"sheet", // 509-5 — 창에서 고른 지형
);
out.recommendBody = bodies[bodies.length - 1];
out.recommendCalls = calls.filter((c) => !c.startsWith("toast:"));
out.recommendDraftsLeft = {
@@ -164,14 +164,10 @@ def test_화면_고르기_배선():
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
api = (page / "B05_Profile_Api_Replan.ts").read_text(encoding="utf-8")
initial_api = (page / "B05_Profile_Api_RouteInitial.ts").read_text(encoding="utf-8")
# 기본 숨김 · 둘 다 있을 때만 보임 · 기본 라이다
assert '<label class="b05-routeedit__terrain" hidden' in chrome
assert '<option value="las">라이다</option>' in chrome
assert '<option value="sheet">수치지형도</option>' in chrome
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api
# 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 1
assert "terrain: analysisTerrain(projectId)" in initial_api
# 509-5 — 평면은 설계 지형 이름만(둘 다 있을 때) · 고르기 · 지형 실음 = 추천 창(test_509_5)
assert 'data-slot="terrain-name" hidden' in chrome and 'data-act="terrain"' not in chrome
assert 'sources?.sources.includes("las") && sources.sources.includes("sheet")' in main
assert "analysisTerrain" not in api and "analysisTerrain" not in initial_api
assert "body: JSON.stringify(request)," in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
assert "terrain" not in replan_fn
@@ -291,9 +291,8 @@ def test_화면_확인이_고른_지형을_실음():
page = ROOT / "B05_Profile"
apply = (page / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
# 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄
assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply
assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈
assert (
"setTerrainContext(projectId, choosable, designTerrain)" in main
) # 고른 값 없으면 설계 지형
# 509-5 — 지형 바꿈 = 추천 [적용]만(창 지형) · 손 [저장] 초안 = 설계 지형(null)
assert "terrain: settings.terrain," in apply
assert "const settings = () => ({ ...params.snap(), terrain: null });" in apply
assert "stationSnapStep: null,\n terrain,\n" in apply # 창 지형(509-5)
assert "pickedTerrain" not in main and "terrainSelect" not in main
@@ -86,11 +86,10 @@ def test_체인이_만들면_화면이_꺼냄(wired) -> None: # noqa: F811
def test_화면이_실은_분석_지형이_설계_지형이면_기본_갈래(wired) -> None: # noqa: F811
"""화면은 늘 분석 지형을 실음 — 설계 지형(가짜 세계 = 라이다)과 같으면 저장본만 · 다르면 셈."""
"""창 지형 — 설계 지형이든 다른 지형이든 기본 갈래는 저장본만(509-5 · 지형마다 미리 셈)."""
assert _ask(False, mode="gentle", terrain="las").status_code == 409
assert _ask(False, mode="gentle", terrain="sheet").status_code == 409
assert wired.count == 0
assert _ask(False, mode="gentle", terrain="sheet")["stored"] is False
assert wired.count == 1
def test_사용자가_바꾼_값_재계산은_셈(wired) -> None: # noqa: F811
+1 -1
View File
@@ -186,7 +186,7 @@ def test_페이지_묶음_노선_초안_캐시_칸(r):
assert p["before"] == {"saved": None, "pipe": True}
assert (
p["cached"]["rev"] == 1
and p["cached"]["terrain"] == "las"
and p["cached"]["terrain"] is None # 설계 지형(509-5)
and p["cached"]["nodes"] == r["n"]
)
assert p["sameRev"] == 1 # 같은 노선 = 판 번호 그대로
@@ -57,7 +57,7 @@ def test_초안은_계약_RouteDraft_꼴(out):
len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc")
)
assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None]
assert draft["stationSnap"] is True and draft["terrain"] == "sheet"
assert draft["stationSnap"] is True and draft["terrain"] is None # 손 저장 = 설계 지형(509-5)
assert draft["stationSnapStep"] == 20 # 390-5 간격도 함께 실림
assert out["body"]["initial"] == {"mode": "follow"}
assert "balanced_profile" not in out["body"] # 손으로 고친 노선 = 계획고 옮김만(349-0v)
@@ -90,8 +90,15 @@ def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
assert body["draft"]["terrain"] == "sheet" # 창 지형 = 설계 지형(509-5)
# 잠금 막 하나(472-6 6-B — 저장 직전 저장 관 떠 두기는 서버 한 본문 몫 · 추천은 초안을 비우니 안 받음)
assert out["recommendCalls"] == ["overlay:on", "replan", "commit", "overlay:off"] # 초안 전 시설 버림
assert out["recommendCalls"] == [
"overlay:on",
"replan",
"commit",
"grid:forget", # 새 설계 지형 격자로(509-2)
"overlay:off",
] # 초안 전 시설 버림
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
@@ -79,7 +79,7 @@ def test_기본값_판정() -> None:
}
assert is_default("gentle", None, None, None)
assert is_default("gentle", None, {"min_radius_m": None}, None)
assert not is_default("gentle", None, None, "las")
assert is_default("gentle", None, None, "las") # 지형 안 따짐(509-5 · 지형마다 미리 셈)
assert not is_default("gentle", None, {"min_radius_m": 15.0}, None)
assert is_default("gentle", None, {"min_radius_m": 15.0}, None, saved)
assert not is_default("gentle", None, {"min_radius_m": 16.0}, None, saved)
@@ -11,6 +11,11 @@ from pathlib import Path
import pytest
# 509-5 — 평면 분석 지형 고르기(화면 취향 기억)를 걷음 · 지형 고르기 = 추천 창 안(test_509_5_*)
pytestmark = pytest.mark.skip(
reason="509-5 평면 분석 지형 고르기 걷음 — test_509_5_recommend_terrain"
)
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
@@ -102,6 +102,11 @@ def test_모달이_여는_갈래_먼저(monkeypatch) -> None:
lambda _root: {"logic": "gentle", "weights": None, "criteria": None},
)
monkeypatch.setattr(router, "compute_initial", compute)
async def terrains(_pid, _root):
return ["las"] # 지형 하나(509-5 지형마다 = test_509_5)
monkeypatch.setattr(router, "precompute_terrains", terrains)
asyncio.run(router.precompute_defaults(uuid4()))
assert asked[0] == "gentle" and asked[-1] == "follow"
assert sorted(asked) == sorted(DEFAULT_MODES)
@@ -161,7 +161,7 @@ def test_편집기_배선() -> None:
"lineChanged(history) || otherChanged()" in main.split("function tellDraft()", 1)[1][:400]
)
assert (
"const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();"
"const otherChanged = (): boolean => snap.changed();" # 지형 고르기 = 추천 창(509-5)
in main
) # 433-14 ②
assert "plan.node_edits" in main
+56
View File
@@ -0,0 +1,56 @@
"""509-2 수치지형도 격자 브라우저로 — 적용 지형(설계 지형) = 수치지형도일 때만 그 격자를 받음 · 노드
끌기 지반고 서버 묻기 걷음 · 횡단 비탈 넓힘도 같은 격자(509-4 G2).
./venv/Scripts/python.exe -m pytest resources/tester/test_509_2_sheet_grid.py -q
분석 지형 = 설계 지형 하나(509-5) — 받는 격자는 늘 설계 지형 것 · 추천 [적용]이 지형을 바꾸면 버림.
"""
from pathlib import Path
from B05_Profile.B05_Profile_Router_Terrain import _design_grid
from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER
ROOT = Path(__file__).resolve().parents[2]
SHEET = {"source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": True}
MODEL = f"B04_PreProcess/models/dtm_{SHEET_ANALYSIS_FILTER}.npz"
def test_설계_지형_수치지형도면_그_격자_자리(tmp_path) -> None:
grid = _design_grid(SHEET, tmp_path, (41, "dtm", MODEL))
assert grid == {"model_id": 41, "smooth": True, "signature": "."}
def test_확정_모델이_다른_파일이면_격자_없음(tmp_path) -> None:
las = "B04_PreProcess/models/dtm_classification.npz"
assert _design_grid(SHEET, tmp_path, (41, "dtm", las)) is None
assert _design_grid(SHEET, tmp_path, None) is None
def _ts(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_지반고는_설계_지형_격자_서버_묻기_없음() -> None:
replan = _ts("B05_Profile/B05_Profile_Api_Replan.ts")
elevations = replan.split("export async function fetchRouteElevations", 1)[1][:700]
assert "if (local) {" in elevations # 받아 둔 격자면 늘 브라우저 표본
assert "JSON.stringify({ points })" in elevations # 서버 길도 설계 지형
# 계산 묶음 초안 지형 = null(설계) → ground 단계가 격자를 씀(terrain === null)
ground = _ts("B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts")
assert "(terrain === null || terrain === local.design)" in ground
pipeline = _ts("A00_Common/design/design_pipeline_page.ts")
assert "terrain: null };" in pipeline
# 횡단 비탈 넓힘 = ground 와 같은 격자(509-4 G2 · 적용 지형 것)
assert "createCrossStage({ grid: groundDeps.grid })" in pipeline
def test_적용으로_지형이_바뀌면_격자_버림() -> None:
grid = _ts("B05_Profile/B05_Profile_Api_TerrainGrid.ts")
forget = grid.split("export function forgetDesignTerrainGrid", 1)[1][:200]
assert "loaded.delete(projectId)" in forget and "ready.delete(projectId)" in forget
apply = _ts("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
recommend = apply.split("export async function saveRecommendedRoute", 1)[1]
assert recommend.index("forgetDesignTerrainGrid(projectId)") < recommend.index(
"await rebuildAfterRouteSave(projectId)"
)
@@ -0,0 +1,155 @@
"""509-5 분석 지형 고르기 = 추천 노선 창 안 — 지형마다 추천 미리 셈 · 창 지형 바꿈 = 그 지형 저장본 ·
없으면 안내 + [재계산] · [적용] = 창 지형이 설계 지형 · 평면 고르기 없음.
./venv/Scripts/python.exe -m pytest resources/tester/test_509_5_recommend_terrain.py -q
가짜 세계(`test_224_3` wired) — 지형마다 다른 격자(수치지형도 = 1 m 높임)라 저장 키가 갈림.
"""
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import pytest
from test_224_3_recommend_store import STREAMS, _ask, _terrain, wired # noqa: F401 — 픽스처
from B05_Profile import B05_Profile_Router_RouteInitial as router
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES, store_dir
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def two(wired, monkeypatch): # noqa: F811
"""라이다 설계 · 수치지형도 고를 수 있음 — 지형마다 다른 격자."""
import B05_Profile.B05_Profile_Engine_RouteInitial as engine
import common_util.common_util_surface_confirmation as surface
async def terrains(_pid, _root):
return ["las", "sheet"]
monkeypatch.setattr(router, "precompute_terrains", terrains)
monkeypatch.setattr(surface, "analysis_terrain_params", lambda s, t: {**s, "t": t})
monkeypatch.setattr(
engine,
"load_terrain_and_streams",
lambda _root, selection: (_terrain(1.0 if selection.get("t") == "sheet" else 0.0), STREAMS),
)
return wired
def _saved(root: Path) -> list[dict]:
return [json.loads(p.read_text(encoding="utf-8")) for p in store_dir(root).glob("*.json")]
def test_미리_셈이_지형마다_추천_다섯(two) -> None:
asyncio.run(router.precompute_defaults(uuid4()))
assert two.count == 2 * len(DEFAULT_MODES)
saved = _saved(two.root)
assert sorted({item["request"]["terrain"] for item in saved}) == ["las", "sheet"]
assert all(item["default"] for item in saved)
# 창 지형 바꿈 = 그 지형 저장본(셈 없음) · 두 지형 답이 따로
for mode in DEFAULT_MODES:
assert _ask(False, mode=mode, terrain="sheet")["stored"] is True
assert _ask(False, mode=mode, terrain="las")["stored"] is True
assert two.count == 2 * len(DEFAULT_MODES)
def test_미리_셈_없는_지형은_안내_재계산은_셈(two) -> None:
"""기존 프로젝트(수치지형도 미리 셈 없음) — 안내 한 줄 · [재계산]이면 그 자리 셈."""
missing = _ask(False, mode="gentle", terrain="sheet")
assert missing.status_code == 409
message = json.loads(missing.body)["message"]
assert "전처리를 다시 하면" in message and "[재계산]" in message
assert two.count == 0
assert _ask(False, mode="gentle", terrain="sheet", recompute=True)["stored"] is False
assert two.count == 1
assert _ask(False, mode="gentle", terrain="sheet")["stored"] is True
def test_미리_셈_지형_차례_설계_먼저(monkeypatch) -> None:
import common_util.common_util_surface_confirmation as surface
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
async def params(_c, _pid):
return {"source_filter": surface.SHEET_ANALYSIS_FILTER}
async def choosable(_c, _pid, _params, _dir):
return ["las", "sheet"]
monkeypatch.setattr(router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(surface, "get_surface_confirmation_params", params)
monkeypatch.setattr(terrain_router, "choosable_terrains", choosable)
assert asyncio.run(router.precompute_terrains(uuid4(), ROOT)) == ["sheet", "las"]
def test_고를_지형은_DB_모델_있을_때만(tmp_path, monkeypatch) -> None:
import B04_PreProcess.B04_PreProcess_Service as service
from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER
(tmp_path / f"dtm_{SHEET_ANALYSIS_FILTER}.npz").write_bytes(b"")
params = {"source_filter": "classification", "method": "dtm", "smooth": False}
found: list[str] = []
async def missing(_c, _pid, selection):
found.append(selection["source_filter"])
raise LookupError
async def present(_c, _pid, _selection):
return 7
monkeypatch.setattr(service, "find_surface_model_for_selection", missing)
pick = terrain_router.choosable_terrains
assert asyncio.run(pick(None, uuid4(), params, tmp_path)) == ["las"]
assert found == [SHEET_ANALYSIS_FILTER]
monkeypatch.setattr(service, "find_surface_model_for_selection", present)
assert asyncio.run(pick(None, uuid4(), params, tmp_path)) == ["las", "sheet"]
def test_지형_바꿈은_추천_적용만_초기값_다시_세움() -> None:
replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
save = replan.split("async def _save_route(", 1)[1]
assert "request.terrain if as_initial else None," in save # 손 [저장]은 지형 안 바꿈
assert save.index("await _recompute(") < save.index("refresh_initial_snapshot(project_id")
recompute = replan.split("async def _recompute(", 1)[1].split("\nasync def ")[0]
assert "switch_design_terrain(connection, project_id, terrain)" in recompute
def _ts(name: str) -> str:
return (ROOT / "B05_Profile" / name).read_text(encoding="utf-8")
def test_창_지형_고르기_배선() -> None:
modal = _ts("B05_Profile_UI_RouteEdit_Recommend.ts")
assert 'data-act="terrain"' in modal and "data-terrain-row hidden" in modal
assert 'params.terrains.includes("las") && params.terrains.includes("sheet")' in modal
change = modal.split('terrainSelect.addEventListener("change"', 1)[1][:300]
assert "void run();" in change # 바꾸면 그 지형 저장본(없으면 안내 · [재계산])
assert "recompute,\n terrain,\n" in modal # 서버에 창 지형
initial = _ts("B05_Profile_UI_RouteEdit_Initial.ts")
assert "terrain: params.terrains()?.design" in initial # 처음 = 설계 지형
assert "return { ...normalizeRouteInitial(" in initial and "terrain };" in initial
assert "picked.terrain ?? null," in initial # [적용] = 창 지형
api = _ts("B05_Profile_Api_RouteInitial.ts")
assert "terrain?: AnalysisTerrain;" in api and "body: JSON.stringify(request)," in api
def test_평면_지형_고르기_없음_이름만() -> None:
chrome = _ts("B05_Profile_UI_RouteEdit_Chrome.ts")
main = _ts("B05_Profile_UI_RouteEdit.ts")
assert 'data-act="terrain"' not in chrome and 'data-slot="terrain-name"' in chrome
assert "terrainName.textContent = `지형 ${" in main
assert "terrainSelect" not in main and "setAnalysisTerrain" not in main
assert "routeedit-terrain" not in (ROOT / "A00_Common/b_page_state.ts").read_text(
encoding="utf-8"
)
pipeline = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8")
assert "terrain: null }; // 설계 지형(509-5)" in pipeline
assert len(pipeline.splitlines()) <= 700 and len(main.splitlines()) <= 700
@@ -231,8 +231,8 @@ def test_노선_추천_다듬기_23_3():
"|",
'data-act="station-snap"',
'data-act="snap-step"', # 390-2 스냅 간격 = [측점 스냅] 오른쪽
"|", # 390-1 [측점 스냅] | 분석 지형
'class="b05-routeedit__terrain" hidden',
"|", # 390-1 [측점 스냅] | 설계 지형 이름(509-5)
'class="b05-routeedit__terrain" data-slot="terrain-name" hidden',
]
at = 0
for mark in order: