fix(B05): 계획노선 편집 창 파란 반폭 선 길이를 「횡단 반폭」 적용값 양쪽으로 (PLAN 22-1)
- 횡단 미리보기 요청에 적용 반폭을 실어 보내고 서버가 덮은 반폭을 되돌려 줌 - 파란 선 좌·우 길이 = 그 반폭 (원지반 선 범위와 같은 값) · 적용한 적 없으면 서버 기본값 - 시험 test_b05_routeedit_cross_width.py 추가 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -164,6 +164,8 @@ export interface CrossPreviewResponse {
|
||||
uphill_side: string | null;
|
||||
plan_radius_m: number | null;
|
||||
curve_widening_m: number | null;
|
||||
/** 샘플이 덮은 횡단 반폭(m) — 요청값, 비었으면 서버 기본값. */
|
||||
cross_half_width_m: number;
|
||||
/** 원지반 횡단 샘플. */
|
||||
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
|
||||
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
|
||||
@@ -180,6 +182,8 @@ export interface CrossPreviewRequest {
|
||||
chainage_m: number;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
/** 횡단 반폭(m) — 없으면 서버 기본값. */
|
||||
cross_half_width_m?: number;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
|
||||
@@ -118,6 +118,8 @@ class CrossPreviewRequest(BaseModel):
|
||||
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
|
||||
min_radius_m: float = Field(12.0, gt=0)
|
||||
station_interval_m: float | None = None
|
||||
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
|
||||
cross_half_width_m: float | None = Field(None, gt=0)
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
@@ -155,10 +157,12 @@ def _cross_preview(
|
||||
radii=[vertex.radius_m for vertex in request.vertices],
|
||||
)
|
||||
interval = request.station_interval_m
|
||||
options = (
|
||||
SectionGenerationOptions(station_interval_m=float(interval))
|
||||
defaults = SectionGenerationOptions()
|
||||
options = SectionGenerationOptions(
|
||||
station_interval_m=float(interval)
|
||||
if interval and interval > 0
|
||||
else SectionGenerationOptions()
|
||||
else defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||
)
|
||||
result = generate_sections(built.vertices, sampler, options)
|
||||
sections = result["cross_sections"]
|
||||
@@ -184,6 +188,8 @@ def _cross_preview(
|
||||
"uphill_side": section.get("uphill_side"),
|
||||
"plan_radius_m": section.get("plan_radius_m"),
|
||||
"curve_widening_m": section.get("curve_widening_m"),
|
||||
# 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다.
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"samples": section["samples"],
|
||||
"design": design,
|
||||
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
|
||||
|
||||
@@ -21,9 +21,9 @@ import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_
|
||||
import {
|
||||
buildCrossSvg,
|
||||
CROSS_PLOT_PAD,
|
||||
designHalfWidths,
|
||||
summarizeCross,
|
||||
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
||||
import { readByKey, stateKey } from "../A00_Common/b_page_state";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
|
||||
export interface CrossPreviewParams {
|
||||
@@ -40,7 +40,7 @@ export interface CrossPreviewParams {
|
||||
onShown?: () => void;
|
||||
}
|
||||
|
||||
/** 위 판에 떠 있는 측점과 그 좌·우 설계 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
|
||||
/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
|
||||
export interface CrossWatch {
|
||||
chainageM: number;
|
||||
leftM: number;
|
||||
@@ -64,6 +64,26 @@ interface CrossPane {
|
||||
wait: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 우측 패널 「횡단 반폭(m)」에 **적용해 둔 값**(m). 적용한 적이 없으면 undefined — 서버가 기본값을 쓴다.
|
||||
*
|
||||
* B06 이 [적용]에서 노선 단위 세션 키에 적는다. 이 창은 노선 번호를 몰라 프로젝트 접두어로
|
||||
* 훑고, 노선이 여럿 남아 있으면 번호가 가장 큰(최신) 것을 쓴다.
|
||||
*/
|
||||
function appliedHalfWidth(projectId: string): number | undefined {
|
||||
const prefix = stateKey("cross-display", projectId, "");
|
||||
if (!prefix) return undefined;
|
||||
try {
|
||||
const latest = Object.keys(window.sessionStorage)
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0];
|
||||
const value = Number(latest ? readByKey(latest) : NaN);
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createPane(title: string, empty: string): CrossPane {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-routeedit__cross";
|
||||
@@ -139,6 +159,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
const preview = await fetchCrossPreview(params.projectId, {
|
||||
...request,
|
||||
chainage_m: chainageM,
|
||||
cross_half_width_m: appliedHalfWidth(params.projectId),
|
||||
});
|
||||
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
|
||||
shown = preview;
|
||||
@@ -164,8 +185,14 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
await load(watching, true);
|
||||
},
|
||||
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
|
||||
// 반폭은 마지막으로 셈한 횡단 것이고, 노드를 놓으면 `refresh` 가 새 값으로 바꾼다.
|
||||
// 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다.
|
||||
watched: () =>
|
||||
watching === null || !shown ? null : { chainageM: watching, ...designHalfWidths(shown) },
|
||||
watching === null || !shown
|
||||
? null
|
||||
: {
|
||||
chainageM: watching,
|
||||
leftM: shown.cross_half_width_m,
|
||||
rightM: shown.cross_half_width_m,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -344,7 +344,8 @@ function drawMeasureMarks(
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 그 측점 설계 반폭.
|
||||
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 우측 패널 「횡단 반폭」
|
||||
* (평면 거리 · 횡단 미리보기의 원지반 선 범위와 같다).
|
||||
*
|
||||
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
|
||||
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 —
|
||||
* `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다.
|
||||
* 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
Object.defineProperty(session, "length", { get: () => session._keys().length });
|
||||
session._keys = () => {
|
||||
const keys = [];
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const key = session.key(i);
|
||||
if (key === null) break;
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
// `Object.keys(sessionStorage)` 가 저장된 키를 돌려주도록 실제 브라우저처럼 속성으로도 둔다.
|
||||
const sessionProxy = new Proxy(session, {
|
||||
ownKeys: () => [...new Set(session._keys())],
|
||||
getOwnPropertyDescriptor: (target, key) =>
|
||||
session._keys().includes(key)
|
||||
? { enumerable: true, configurable: true, value: target.getItem(key) }
|
||||
: Reflect.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
globalThis.window = { localStorage: memoryStorage(), sessionStorage: sessionProxy };
|
||||
|
||||
const fakeEl = () => ({
|
||||
className: "",
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
querySelector: () => fakeEl(),
|
||||
replaceChildren() {},
|
||||
append() {},
|
||||
getBoundingClientRect: () => ({ width: 300, height: 200 }),
|
||||
});
|
||||
globalThis.document = { createElement: fakeEl };
|
||||
|
||||
const asked = [];
|
||||
const STUBS = {
|
||||
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} },
|
||||
"../common_util/common_util_station": { stationLabel: (m) => String(m) },
|
||||
"./B05_Profile_UI_RouteEdit_Cross_Draw": {
|
||||
buildCrossSvg: () => ({ svg: {}, layer: {}, width: 1, height: 1, content: {} }),
|
||||
CROSS_PLOT_PAD: 0,
|
||||
summarizeCross: () => "",
|
||||
},
|
||||
// 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20.
|
||||
"./B05_Profile_Api_Replan": {
|
||||
fetchCrossPreview: async (_project, request) => {
|
||||
asked.push(request.cross_half_width_m ?? null);
|
||||
return {
|
||||
chainage_m: request.chainage_m,
|
||||
cross_half_width_m: request.cross_half_width_m ?? 20,
|
||||
samples: [],
|
||||
design: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { createCrossPreview } = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"),
|
||||
);
|
||||
|
||||
async function run() {
|
||||
const cross = createCrossPreview({
|
||||
projectId: "p-1",
|
||||
side: { append() {} },
|
||||
request: () => ({ vertices: [], min_radius_m: 12, station_interval_m: 20 }),
|
||||
});
|
||||
const out = {};
|
||||
out.before = cross.watched();
|
||||
await cross.open(100);
|
||||
out.empty = { asked: asked.at(-1), watched: cross.watched() };
|
||||
session.setItem("aislo:draft:cross-display:p-1:3", "15");
|
||||
await cross.refresh();
|
||||
out.applied15 = { asked: asked.at(-1), watched: cross.watched() };
|
||||
session.setItem("aislo:draft:cross-display:p-1:12", "8.5");
|
||||
session.setItem("aislo:draft:cross-display:p-2:99", "40");
|
||||
await cross.refresh();
|
||||
out.latestRoute = { asked: asked.at(-1), watched: cross.watched() };
|
||||
session.setItem("aislo:draft:cross-display:p-1:12", "abc");
|
||||
await cross.refresh();
|
||||
out.badValue = { asked: asked.at(-1), watched: cross.watched() };
|
||||
return out;
|
||||
}
|
||||
run().then((out) => console.log(JSON.stringify(out)));
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증.
|
||||
|
||||
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값.
|
||||
선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다.
|
||||
Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_routeedit_cross_width.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_측점을_고르기_전에는_선이_없다(results):
|
||||
assert results["before"] is None
|
||||
|
||||
|
||||
def test_적용한_적_없으면_서버_기본값_양쪽(results):
|
||||
assert results["empty"]["asked"] is None
|
||||
assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20}
|
||||
|
||||
|
||||
def test_적용값이_있으면_그_값_양쪽(results):
|
||||
assert results["applied15"]["asked"] == 15
|
||||
assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15}
|
||||
|
||||
|
||||
def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results):
|
||||
assert results["latestRoute"]["asked"] == 8.5
|
||||
assert results["latestRoute"]["watched"]["leftM"] == 8.5
|
||||
|
||||
|
||||
def test_망가진_값은_기본값으로(results):
|
||||
assert results["badValue"]["asked"] is None
|
||||
assert results["badValue"]["watched"]["rightM"] == 20
|
||||
Reference in New Issue
Block a user