feat(B05 노선편집): 345-5 평면 안 종단 띠 지움 — 원지반선 · 계획 종단선 · 연장 띠(_RouteEdit_ProfileStrip) · 띠 칸 · CSS · 띠 전용 요청(/route/profile-preview · fetchProfilePreview · balanced_profile_of terrain) · 눈금 그리기 걷음 · 아래 종단 판 종단 그래프 그대로

- [노선 반영] 균형 종단 여부 = 지금 추천 갈래(띠가 보이던 값과 같은 규칙)
- 시험: test_75 → 띠 없음 시험 · test_133_1 분석 지형 셋 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-16 11:59:27 +09:00
co-authored by Claude Opus 5.5
parent e41300aad6
commit cfeca9c647
9 changed files with 28 additions and 374 deletions
+1 -20
View File
@@ -5,10 +5,9 @@
* GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m)
* POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
* POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
* POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠)
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
*
* 편집 창 분석(지반고 · 횡단 미리보기 · 종단 띠 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
* 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
* 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음.
*
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
@@ -260,21 +259,3 @@ export async function resetRoutePlan(projectId: string): Promise<RouteReplanResp
REPLAN_TIMEOUT_MS,
);
}
/** 지금 노드의 **균형 종단**(PLAN 75-1) — [확인]과 같은 폴리라인 · 같은 셈. 파일은 안 쓴다.
* 답의 `profile` 은 추천 창과 같은 꼴(`parseBalance` 로 편다). */
export async function fetchProfilePreview(
projectId: string,
vertices: RouteReplanVertex[],
criteria: unknown,
): Promise<unknown> {
const payload = await requestJson<{ profile: unknown }>(
`/projects/${projectId}/route/profile-preview`,
{
method: "POST",
body: JSON.stringify({ vertices, criteria, terrain: analysisTerrain(projectId) }),
},
120000,
);
return payload.profile;
}
+4 -57
View File
@@ -9,11 +9,10 @@ import functools
import logging
import time
from pathlib import Path
from typing import Any, Literal
from typing import Any
from uuid import UUID
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice
@@ -277,19 +276,12 @@ async def balanced_profile_of(
points: list[dict[str, float]],
criteria: Any,
design_curves: list[dict[str, Any]] | None = None,
terrain: Literal["las", "sheet"] | None = None,
) -> dict[str, Any] | None:
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀.
`terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — None = 지금 설계 지형([확인] 체인).
"""
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 몫(지금 설계 지형)."""
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR
from common_util.common_util_surface_confirmation import (
analysis_terrain_params,
get_surface_confirmation_params,
)
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
grade_class, design_speed, terrain_type = await replan._road_settings(project_id)
@@ -299,9 +291,8 @@ async def balanced_profile_of(
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
selection = analysis_terrain_params(selection, terrain)
try:
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) — 띠 값 = [확인] 뒤 값
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님)
surface = await asyncio.to_thread(
build_surface_sampler,
project_root / _MODELS_SUBDIR,
@@ -358,47 +349,3 @@ async def refresh_initial_snapshot(
logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id)
except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다
logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id)
class ProfilePreviewRequest(BaseModel):
"""편집 창 종단 띠(PLAN 75-1) — 지금 노드 · 지금 추천 제약."""
vertices: list[replan.RouteVertexInput] = Field(..., min_length=2)
criteria: replan.RouteInitialCriteria | None = None
terrain: Literal["las", "sheet"] | None = None
@replan.router.post("/{project_id}/route/profile-preview", response_model=None)
async def profile_preview(
project_id: UUID, request: ProfilePreviewRequest
) -> dict[str, Any] | JSONResponse:
"""지금 노드의 균형 종단 — [확인]과 같은 폴리라인(같은 R · CSV 자릿수) · 같은 셈.
파일 · DB 를 쓰지 않는다. 「종단 반영」 켬일 때 편집 창 띠가 노드를 놓을 때마다 부른다.
"""
from common_util.common_util_route_polyline import build_planned_polyline
paths = await replan._project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
project_root, _ = paths
radius_m, _, _ = await replan._plan_criteria(project_id)
built = build_planned_polyline(
[(v.x, v.y) for v in request.vertices],
min_radius_m=radius_m,
simplify=False,
curve_flags=[bool(v.curve) for v in request.vertices],
radii=[v.radius_m for v in request.vertices],
)
# [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로.
points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices]
curves = [curve.as_dict() for curve in built.curves]
profile = await balanced_profile_of(
project_id, project_root, points, request.criteria, curves, request.terrain
)
if profile is None:
return JSONResponse(
status_code=409,
content={"status": "error", "message": "이 노선으로 균형 종단을 세우지 못했습니다."},
)
return {"status": "success", "project_id": str(project_id), "profile": profile}
+1 -22
View File
@@ -68,7 +68,6 @@ import {
writeRouteLocks,
} from "./B05_Profile_UI_RouteEdit_Lock";
import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial";
import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip";
import {
applyArcLocks,
applyCurveLimits,
@@ -277,22 +276,6 @@ export async function mountRouteEditor(
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
});
/** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */
const strip = createProfileStrip({
root: overlay.querySelector<HTMLElement>("[data-strip]")!,
projectId,
withProfile: initial.withProfile,
now: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
line: plannedLine.length ? plannedLine : planned,
criteria: initial.current().criteria,
}),
});
// [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림.
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => {
const snapshot = history?.reset();
@@ -463,7 +446,6 @@ export async function mountRouteEditor(
draw();
if (record) history?.commit(snapshotNow());
historyControls.sync();
strip.refresh();
}
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
@@ -544,7 +526,6 @@ export async function mountRouteEditor(
commit: () => {
history?.commit(snapshotNow());
historyControls.sync();
strip.refresh();
},
});
@@ -594,7 +575,7 @@ export async function mountRouteEditor(
terrain: pickedTerrain,
stationSnap: () => stationSnap,
initial,
profileShown: strip.settle,
profileShown: async () => initial.withProfile(), // 띠 없음(345-5) — 지금 추천 갈래가 정함
onApplied,
});
@@ -682,7 +663,6 @@ export async function mountRouteEditor(
useBackground(background);
measure.clear();
slope.refresh();
strip.refresh();
void crossPreview.refresh();
showStatus();
draw();
@@ -692,7 +672,6 @@ export async function mountRouteEditor(
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
showStatus();
draw();
strip.refresh();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
}
@@ -59,7 +59,7 @@ export function createRouteEditChrome(
<span data-slot="tools"></span>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<label class="b05-routeedit__terrain" hidden
title="편집 창 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 종단 띠 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
title="편집 창 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
<select class="ui-select ui-select--sm" data-act="terrain">
<option value="las">라이다</option>
<option value="sheet">수치지형도</option>
@@ -112,12 +112,6 @@ export function createRouteEditChrome(
</span>
</div>
</div>
<div class="b05-routeedit__strip" data-strip>
<div class="b05-routeedit__strip-head">
<span data-strip-note>종단을 읽는 중…</span>
</div>
<div class="b05-routeedit__strip-graph" data-strip-graph></div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<aside class="b05-routeedit__side-wrap">
@@ -1,116 +0,0 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_ProfileStrip.ts
* 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 ·
* 측점 눈금 한 줄.
*
* 보여 주기만 한다(PLAN 77-5) — 종단 여부는 지금 추천 갈래가 정한다(`withProfile`). 종단 갈래 =
* 계획선은 지금 노드로 서버가 다시 낸 균형 종단(`/route/profile-preview` — [확인] 체인과 같은 셈).
* 예상노선 추적 = 지반선만(계획고 = 지반고 · `/route/elevations`). [확인]도 같은 규칙을 탄다
* (`_Apply` 의 `balanced_profile`).
*
* 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게.
* 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
* ========================================================================== */
import {
analysisTerrain,
fetchProfilePreview,
fetchRouteElevations,
type RouteReplanVertex,
} from "./B05_Profile_Api_Replan";
import { parseBalance, profileSvg } from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
import { lengthOf, pointsAt, stationChainages } from "./B05_Profile_UI_RouteEdit_Slope";
type Vertex = [number, number];
/** 지반선만 그릴 때 측점 간격(m) — 균형 종단(서버 `STATION_M`)과 같음. */
const STATION_M = 20;
export interface ProfileStripParams {
root: HTMLElement;
projectId: string;
/** 지금 추천 갈래가 종단을 반영하는가(예상노선 추적만 아님). */
withProfile: () => boolean;
/** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */
now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray<Vertex>; criteria: unknown };
}
export interface ProfileStrip {
/** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용) — 다시 묻는다. */
refresh: () => void;
/** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지
* 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 갈래 값. */
settle: () => Promise<boolean>;
}
export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
const { root, projectId } = params;
const note = root.querySelector<HTMLElement>("[data-strip-note]")!;
const graph = root.querySelector<HTMLElement>("[data-strip-graph]")!;
let seq = 0;
/** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 갈래 종단이면 다시 묻지 않는다. */
let lastKey = "";
/** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */
let pending: Promise<void> = Promise.resolve();
let shownOn: boolean | null = null;
async function groundOnly(line: ReadonlyArray<Vertex>) {
const stationsM = stationChainages(lengthOf(line), STATION_M);
const z = await fetchRouteElevations(projectId, pointsAt(line, stationsM));
const known = stationsM.flatMap((s, i) => (z[i] === null ? [] : [[s, z[i]!] as const]));
if (known.length < 2) throw new Error("노선 위 지반고를 읽지 못했습니다.");
return {
stationsM: known.map(([s]) => s),
groundM: known.map(([, h]) => h),
planM: [] as number[],
};
}
function refresh(): void {
const on = params.withProfile();
const { vertices, line, criteria } = params.now();
const key = JSON.stringify([on, vertices, criteria, analysisTerrain(projectId)]);
if (vertices.length < 2 || line.length < 2 || key === lastKey) return;
lastKey = key;
pending = draw(on, vertices, line, criteria);
}
async function draw(
on: boolean,
vertices: RouteReplanVertex[],
line: ReadonlyArray<Vertex>,
criteria: unknown,
): Promise<void> {
const ticket = ++seq;
root.classList.add("is-waiting");
note.textContent = "종단을 읽는 중…";
try {
const profile = on
? parseBalance(await fetchProfilePreview(projectId, vertices, criteria))
: await groundOnly(line);
if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다.
if (!profile) throw new Error("균형 종단을 세우지 못했습니다.");
const total = profile.stationsM[profile.stationsM.length - 1];
graph.innerHTML = profileSvg(profile, true);
shownOn = on;
note.textContent = `${on ? "원지반선 · 계획 종단선" : "원지반선(계획고 = 지반고)"} · 연장 ${total.toFixed(1)}m`;
} catch (error) {
if (ticket !== seq) return;
lastKey = ""; // 실패 — 다음 번에 다시 묻는다.
shownOn = null;
graph.innerHTML = "";
note.textContent = error instanceof Error ? error.message : "종단을 읽지 못했습니다.";
} finally {
if (ticket === seq) root.classList.remove("is-waiting");
}
}
return {
refresh,
settle: async () => {
refresh();
await pending;
return shownOn ?? params.withProfile();
},
};
}
@@ -85,11 +85,9 @@ const PROFILE_H = 96;
const PAD = 6;
/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림).
* `ticks` 면 아래 가장자리에 측점 눈금(100 m 마다 길게) — 편집 창 종단 띠(PLAN 75-1).
* 계획선이 비면(종단 반영 끔) 지반선만. */
export function profileSvg(
balance: Pick<BalanceProfile, "stationsM" | "groundM" | "planM"> | null,
ticks = false,
): string {
if (!balance) return "";
const { stationsM, groundM, planM } = balance;
@@ -104,18 +102,8 @@ export function profileSvg(
return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join("");
const tickPath = ticks
? stationsM
.map((s) => {
const x = (PAD + ((PROFILE_W - 2 * PAD) * s) / total).toFixed(1);
const long = Math.abs(s / 100 - Math.round(s / 100)) < 1e-6;
return `M${x} ${PROFILE_H}V${PROFILE_H - (long ? 7 : 3)}`;
})
.join("")
: "";
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
${ticks ? `<path class="is-tick" d="${tickPath}" vector-effect="non-scaling-stroke" />` : ""}
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
<path class="is-plan" d="${planM.length ? path(planM) : ""}" vector-effect="non-scaling-stroke" /></svg>`;
}
@@ -399,59 +399,3 @@
background: var(--color-primary, #7c3aed);
color: #fff;
}
/* 지도 아래 종단 띠(PLAN 75-1) — 원지반선 · 계획선 · 측점 눈금 한 줄. 묻는 동안 옛 그림은 흐리게. */
.b05-routeedit__strip {
display: flex;
flex: none;
flex-direction: column;
gap: var(--spacing-4, 4px);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.b05-routeedit__strip-head {
display: flex;
align-items: center;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-variant-numeric: tabular-nums;
}
/* 그림 칸 높이는 미리 잡아 둠 — 응답이 늦게 와도 띠 높이가 안 바뀌어 지도 크기 셈이 안 어긋남. */
.b05-routeedit__strip-graph {
flex: none;
height: 88px;
}
.b05-routeedit__strip-graph svg {
display: block;
width: 100%;
height: 88px;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
transition: opacity 0.15s;
}
.b05-routeedit__strip.is-waiting .b05-routeedit__strip-graph svg {
opacity: 0.35;
}
.b05-routeedit__strip-graph .is-ground {
fill: none;
stroke: var(--color-text-secondary);
stroke-width: 1.5;
}
.b05-routeedit__strip-graph .is-plan {
fill: none;
stroke: var(--color-primary);
stroke-width: 2;
}
.b05-routeedit__strip-graph .is-tick {
stroke: var(--color-text-secondary);
stroke-width: 1;
}
@@ -15,7 +15,6 @@ import pytest
from pydantic import ValidationError
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_RouteInitial as initial
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
from common_util.common_util_surface_confirmation import (
SHEET_ANALYSIS_FILTER,
@@ -143,8 +142,6 @@ def test_허용_목록_밖_거절(bad):
terrain_router.CrossPreviewRequest(
vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad
)
with pytest.raises(ValidationError):
initial.ProfilePreviewRequest(vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], terrain=bad)
with pytest.raises(ValidationError):
replan.RouteInitialRequest(terrain=bad)
@@ -170,8 +167,8 @@ def test_화면_고르기_배선():
assert '<option value="sheet">수치지형도</option>' in chrome
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : fallback' in api
# 모달 분석 넷이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 3
# 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 2
assert "terrain: analysisTerrain(projectId)" in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
assert "terrain" not in replan_fn
+19 -79
View File
@@ -1,4 +1,4 @@
"""편집 창 종단 띠 — [확인]과 같은 노선 점으로 균형 종단을 셈한다(PLAN 75-1).
"""편집 창 종단 띠 — 없앰(345-5 · 아래 종단 판 종단 그래프가 그 몫) · [확인] 목표 셈 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_75_profile_strip.py -q
"""
@@ -7,90 +7,30 @@ import asyncio
from pathlib import Path
from uuid import uuid4
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_RouteInitial as initial
from common_util.common_util_route_geometry import read_planned_route_csv
NODES = [(0.0, 0.0), (120.0, 35.0), (180.0, 140.0), (310.0, 150.5)]
RADIUS_M = 25.0
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
def _strip_points(monkeypatch, tmp_path: Path, criteria=None) -> tuple[list, object]:
seen: dict = {}
async def paths(_pid):
return tmp_path, "x"
async def plan_criteria(_pid):
return RADIUS_M, 0.0, 0.0
async def profile_of(_pid, _root, points, crit, curves=None, _terrain="las"):
seen["points"], seen["criteria"], seen["curves"] = points, crit, curves
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_plan_criteria", plan_criteria)
monkeypatch.setattr(initial, "balanced_profile_of", profile_of)
request = initial.ProfilePreviewRequest(
vertices=[
replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None)
for i, (x, y) in enumerate(NODES)
],
criteria=criteria,
)
answer = asyncio.run(initial.profile_preview(uuid4(), request))
assert answer["profile"]["plan_m"] == [1.0, 2.0]
return seen["points"], seen["criteria"]
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_띠_노선점은_확인이_읽는_CSV와_같다(monkeypatch, tmp_path: Path) -> None:
points, _ = _strip_points(monkeypatch, tmp_path)
working = tmp_path / "planned_route.csv"
replan._write_planned_polyline(
working,
NODES,
RADIUS_M,
simplify=False,
curve_flags=[True, True, False, True],
radii=[None, 40.0, None, None],
)
stored = read_planned_route_csv(working)
assert stored is not None
assert [(p["x"], p["y"]) for p in points] == [(v.x, v.y) for v in stored.vertices]
assert len(points) > len(NODES) # 원호가 펴져 있다
def test_띠는_그_노선의_곡선표로_확폭을_맨다(monkeypatch, tmp_path: Path) -> None:
seen: dict = {}
async def paths(_pid):
return tmp_path, "x"
async def plan_criteria(_pid):
return RADIUS_M, 0.0, 0.0
async def profile_of(_pid, _root, _points, _crit, curves=None, _terrain="las"):
seen["curves"] = curves
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_plan_criteria", plan_criteria)
monkeypatch.setattr(initial, "balanced_profile_of", profile_of)
request = initial.ProfilePreviewRequest(
vertices=[
replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None)
for i, (x, y) in enumerate(NODES)
]
)
asyncio.run(initial.profile_preview(uuid4(), request))
assert seen["curves"] and 40.0 in [c["radius_m"] for c in seen["curves"]]
assert all({"start", "apex", "end", "radius_m"} <= set(c) for c in seen["curves"])
def test_띠는_지금_추천_제약을_싣는다(monkeypatch, tmp_path: Path) -> None:
criteria = replan.RouteInitialCriteria(max_grade_pct=12.0)
_, got = _strip_points(monkeypatch, tmp_path, criteria)
assert got.max_grade_pct == 12.0
def test_띠_없음_화면_요청_서버_길():
assert not (B05 / "B05_Profile_UI_RouteEdit_ProfileStrip.ts").exists()
chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
assert "data-strip" not in chrome and "종단 띠" not in chrome
assert "routeedit__strip" not in _read("B05_Profile_UI_Style_RouteEdit.css")
editor = _read("B05_Profile_UI_RouteEdit.ts")
assert "ProfileStrip" not in editor and "strip." not in editor
# [노선 반영]의 균형 종단 여부 = 지금 추천 갈래(띠가 보이던 값과 같은 규칙)
assert "profileShown: async () => initial.withProfile()," in editor
assert "profile-preview" not in _read("B05_Profile_Api_Replan.ts")
assert "is-tick" not in _read("B05_Profile_UI_RouteEdit_Recommend_Profile.ts")
assert not hasattr(initial, "profile_preview") and not hasattr(initial, "ProfilePreviewRequest")
paths = {getattr(r, "path", "") for r in initial.replan.router.routes}
assert not any(p.endswith("/route/profile-preview") for p in paths)
def test_확인_목표는_같은_셈의_측점_계획고(monkeypatch, tmp_path: Path) -> None: