feat(476-2 추천 초기 종단): 추천 모달에 적용될 초기 종단 선 · 한 줄 · [적용] 확인 글
- 서버 initial_profile 펴기(세션에 안 적음) · 실패면 이유 한 줄 - 종단 그림에 초기 종단 선(주황 점선 · 끔이면 지반선 + 초기 종단) · 범례 - 한 줄 = 꼭짓점 · 종단곡선 · 직선화 수 · 최대 종단경사(L) - [적용] = 「종단 손 편집은 새 초기 종단으로 바뀜」 확인 뒤 - 시험 test_476_2 · test_421 [적용] 단언 = 확인 창 뒤 고른 후보 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,12 @@ import type {
|
||||
} from "./B05_Profile_Api_RouteInitial";
|
||||
import { flattenServerPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import { parseBalance, type BalanceProfile } from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
||||
import {
|
||||
parseBalance,
|
||||
parseInitialProfile,
|
||||
type BalanceProfile,
|
||||
type InitialProfile,
|
||||
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -199,6 +204,8 @@ export interface InitialChoice {
|
||||
stored?: boolean;
|
||||
/** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */
|
||||
balance?: BalanceProfile | null;
|
||||
/** 적용될 초기 종단(476) — 모달 미리 보기에만 · 실패면 `{ error }`. 캐시에는 안 적는다. */
|
||||
initialProfile?: InitialProfile | { error: string } | null;
|
||||
/** 서버가 짠 선(원호 정점 · 계획서 351-2) — 모달 미리 보기에만. 캐시에는 안 적는다. */
|
||||
line?: Vertex[] | null;
|
||||
/** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */
|
||||
@@ -359,6 +366,7 @@ export function normalizeRouteInitial(
|
||||
// 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값
|
||||
withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile,
|
||||
balance: parseBalance(m.profile),
|
||||
initialProfile: parseInitialProfile(body.initial_profile),
|
||||
line: Array.isArray(body.planned)
|
||||
? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])])
|
||||
: null,
|
||||
@@ -621,10 +629,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
|
||||
}
|
||||
|
||||
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
|
||||
// 균형 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
|
||||
// 균형 종단 · 초기 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 476 · 351-2) — 캐시에 안 싣는다.
|
||||
writeState(
|
||||
"routeedit-initial",
|
||||
choice && { ...choice, balance: undefined, line: undefined },
|
||||
choice && { ...choice, balance: undefined, initialProfile: undefined, line: undefined },
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@
|
||||
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
|
||||
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
|
||||
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
|
||||
* 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다
|
||||
* (끔이어도) · [적용]은 「종단 손 편집이 새 초기 종단으로 바뀜」 확인 뒤.
|
||||
* ========================================================================== */
|
||||
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||
import type {
|
||||
PrecomputeProgress,
|
||||
@@ -61,6 +68,8 @@ import {
|
||||
import {
|
||||
balanceLine,
|
||||
candidatesTable,
|
||||
initialLine,
|
||||
profileLegend,
|
||||
profileSvg,
|
||||
type BalanceProfile,
|
||||
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
||||
@@ -78,6 +87,10 @@ const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
|
||||
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단",
|
||||
};
|
||||
|
||||
/** [적용] 확인 글(476) — 같은 노선이어도 종단을 새로 세운다. */
|
||||
const APPLY_CONFIRM =
|
||||
"이 노선을 저장하고 미리 본 초기 종단으로 종단을 새로 세웁니다 — 종단 손 편집은 새 초기 종단으로 바뀝니다. 적용할까요?";
|
||||
|
||||
const WEIGHT_NAME: Record<WeightKey, string> = {
|
||||
expected: "예상",
|
||||
earthwork: "토공",
|
||||
@@ -218,6 +231,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<div data-line="metrics"></div>
|
||||
<div data-line="metrics-work"></div>
|
||||
<div data-line="balance" hidden></div>
|
||||
<div data-line="initial" hidden></div>
|
||||
<div class="b05-routerec__balance">
|
||||
<div class="b05-routerec__profile" data-line="profile" hidden></div>
|
||||
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
|
||||
@@ -423,8 +437,16 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const balance = view?.balance ?? null;
|
||||
line("balance").textContent = balanceLine(balance);
|
||||
line("balance").hidden = !balance;
|
||||
line("profile").innerHTML = profileSvg(balance);
|
||||
line("profile").hidden = !balance;
|
||||
// 적용될 초기 종단(476) — 끔이면 지반선 + 초기 종단 · 실패면 이유 한 줄만
|
||||
const initial = view?.initialProfile ?? null;
|
||||
const initialShape = initial && !("error" in initial) ? initial : null;
|
||||
line("initial").textContent = initialLine(initial);
|
||||
line("initial").classList.toggle("is-warn", !!initial && "error" in initial);
|
||||
line("initial").hidden = !initial;
|
||||
line("profile").innerHTML =
|
||||
profileSvg(balance, initialShape) +
|
||||
profileLegend(!!balance?.planM.length, !!initialShape);
|
||||
line("profile").hidden = !balance && !initialShape;
|
||||
line("candidates").innerHTML = candidatesTable([...seen]);
|
||||
line("candidates").hidden = seen.size === 0;
|
||||
const violations = violationsLine(view?.metrics ?? null);
|
||||
@@ -597,10 +619,14 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
});
|
||||
rerun.addEventListener("click", () => void run(true));
|
||||
applyButton.addEventListener("click", () => {
|
||||
if (!preview) return;
|
||||
params.onApply(preview);
|
||||
closed = true;
|
||||
close();
|
||||
const picked = preview;
|
||||
if (!picked) return;
|
||||
void showConfirmDialog(APPLY_CONFIRM, "적용").then((ok) => {
|
||||
if (!ok || closed || preview !== picked) return;
|
||||
params.onApply(picked);
|
||||
closed = true;
|
||||
close();
|
||||
});
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
closed = true;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* 이 값은 **편집 창 안에서만** 산다 — 캐시(`routeedit-initial`)에 적지 않고(`writeInitialChoice`
|
||||
* 가 뺀다) 저장 · initial_snapshot · 확정 종단과 무관하다. 확정 종단은 [확인] 체인이 지금 규칙
|
||||
* (계획고 = 지반고)으로 다시 세운다. 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
|
||||
* 적용될 초기 종단(476 · 요구 276) — 서버가 [적용]과 같은 코드로 후보마다 세운 종단
|
||||
* (`initial_profile` · 438 직선화 · 종단곡선 포함)을 펴서 같은 그림에 선 하나 · 한 줄로 더한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { renderDataTableHtml, type DataColumn } from "@ui/ui_template_data_table";
|
||||
@@ -62,6 +64,69 @@ export function parseBalance(raw: unknown): BalanceProfile | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** 후보 하나의 적용될 초기 종단 — 서버 `initial_profile`(476-1). */
|
||||
export interface InitialProfile {
|
||||
withProfile: boolean;
|
||||
stationsM: number[];
|
||||
groundM: number[];
|
||||
/** 측점 계획고. */
|
||||
planM: number[];
|
||||
/** 종단 표본 간격으로 편 선(종단곡선 포함) — [누가거리, 계획고]. */
|
||||
line: Array<[number, number]>;
|
||||
pviCount: number;
|
||||
curveCount: number;
|
||||
/** 438 로 직선화된 꼭짓점 수. */
|
||||
straightened: number;
|
||||
maxGradePct: number;
|
||||
curveLengthM: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 서버 `initial_profile` 을 편다 — 셈 실패면 `{ error }` · 없거나(옛 답) 꼴이 틀리면 null. */
|
||||
export function parseInitialProfile(raw: unknown): InitialProfile | { error: string } | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const p = raw as Record<string, unknown>;
|
||||
if (typeof p.error === "string") return { error: p.error };
|
||||
const stationsM = numbers(p.stations_m);
|
||||
const groundM = numbers(p.ground_m);
|
||||
const planM = numbers(p.plan_m);
|
||||
const line = Array.isArray(p.line)
|
||||
? p.line
|
||||
.filter((xy): xy is unknown[] => Array.isArray(xy))
|
||||
.map((xy) => [num(xy[0]), num(xy[1])])
|
||||
.filter((xy): xy is [number, number] => xy[0] !== null && xy[1] !== null)
|
||||
: [];
|
||||
if (stationsM.length < 2 || groundM.length !== stationsM.length) return null;
|
||||
if (planM.length !== stationsM.length || line.length < 2) return null;
|
||||
return {
|
||||
withProfile: p.with_profile === true,
|
||||
stationsM,
|
||||
groundM,
|
||||
planM,
|
||||
line,
|
||||
pviCount: num(p.pvi_count) ?? 0,
|
||||
curveCount: num(p.curve_count) ?? 0,
|
||||
straightened: num(p.straightened) ?? 0,
|
||||
maxGradePct: num(p.max_grade_pct) ?? 0,
|
||||
curveLengthM: num(p.curve_length_m),
|
||||
warnings: Array.isArray(p.warnings) ? p.warnings.map(String) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 한 줄 — 「초기 종단: 꼭짓점 12 · 종단곡선 10 · 직선화 3 · 최대 종단경사 11.2%(L 20)」 · 실패면 이유. */
|
||||
export function initialLine(initial: InitialProfile | { error: string } | null): string {
|
||||
if (!initial) return "";
|
||||
if ("error" in initial) return `초기 종단을 셈하지 못했습니다 — ${initial.error}`;
|
||||
const parts = [
|
||||
`초기 종단: 꼭짓점 ${initial.pviCount} · 종단곡선 ${initial.curveCount}`,
|
||||
`직선화 ${initial.straightened}`,
|
||||
`최대 종단경사 ${pct(initial.maxGradePct)}${
|
||||
initial.curveLengthM !== null ? `(L ${initial.curveLengthM})` : ""
|
||||
}`,
|
||||
];
|
||||
return [parts.join(" · "), ...initial.warnings].join(" · ");
|
||||
}
|
||||
|
||||
const m3 = (value: number): string => `${Math.round(value).toLocaleString("ko-KR")}㎥`;
|
||||
const pct = (value: number): string => `${value.toFixed(1)}%`;
|
||||
const signedM3 = (value: number): string => `${value > 0 ? "+" : ""}${m3(value)}`;
|
||||
@@ -84,28 +149,46 @@ const PROFILE_W = 600;
|
||||
const PROFILE_H = 96;
|
||||
const PAD = 6;
|
||||
|
||||
/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림).
|
||||
* 계획선이 비면(종단 반영 끔) 지반선만. */
|
||||
/** 종단 그림(SVG) — 지반선 · 균형 계획선 · 적용될 초기 종단. 가로 = 측점 · 세로 = 표고(창에 맞춰
|
||||
* 늘림). 계획선이 비면(종단 반영 끔) 지반선 · 초기 종단만 · 균형 종단이 없으면 초기 종단의 지반선. */
|
||||
export function profileSvg(
|
||||
balance: Pick<BalanceProfile, "stationsM" | "groundM" | "planM"> | null,
|
||||
initial: Pick<InitialProfile, "stationsM" | "groundM" | "line"> | null = null,
|
||||
): string {
|
||||
if (!balance) return "";
|
||||
const { stationsM, groundM, planM } = balance;
|
||||
const total = stationsM[stationsM.length - 1] || 1;
|
||||
const low = Math.min(...groundM, ...planM);
|
||||
const span = Math.max(Math.max(...groundM, ...planM) - low, 1);
|
||||
const path = (values: number[]): string =>
|
||||
values
|
||||
const base = balance ?? (initial && { ...initial, planM: [] });
|
||||
if (!base) return "";
|
||||
const { stationsM, groundM, planM } = base;
|
||||
const lineS = initial?.line.map(([s]) => s) ?? [];
|
||||
const lineZ = initial?.line.map(([, z]) => z) ?? [];
|
||||
const total = Math.max(stationsM[stationsM.length - 1], ...lineS) || 1;
|
||||
const low = Math.min(...groundM, ...planM, ...lineZ);
|
||||
const span = Math.max(Math.max(...groundM, ...planM, ...lineZ) - low, 1);
|
||||
const path = (xs: number[], zs: number[]): string =>
|
||||
zs
|
||||
.map((z, i) => {
|
||||
const x = PAD + ((PROFILE_W - 2 * PAD) * stationsM[i]) / total;
|
||||
const x = PAD + ((PROFILE_W - 2 * PAD) * xs[i]) / total;
|
||||
const y = PROFILE_H - PAD - ((PROFILE_H - 2 * PAD) * (z - low)) / span;
|
||||
return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join("");
|
||||
const initialPath = initial
|
||||
? `
|
||||
<path class="is-initial" d="${path(lineS, lineZ)}" vector-effect="non-scaling-stroke" />`
|
||||
: "";
|
||||
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
|
||||
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
|
||||
<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>`;
|
||||
role="img" aria-label="종단 미리 보기 — 지반선 · 계획선 · 적용될 초기 종단">
|
||||
<path class="is-ground" d="${path(stationsM, groundM)}" vector-effect="non-scaling-stroke" />
|
||||
<path class="is-plan" d="${planM.length ? path(stationsM, planM) : ""}" vector-effect="non-scaling-stroke" />${initialPath}</svg>`;
|
||||
}
|
||||
|
||||
/** 그림 아래 범례 — 그린 선만. */
|
||||
export function profileLegend(hasPlan: boolean, hasInitial: boolean): string {
|
||||
const item = (cls: string, name: string): string => `<span><i class="${cls}"></i>${name}</span>`;
|
||||
return `<div class="b05-routerec__plegend">${[
|
||||
item("is-ground", "지반선"),
|
||||
hasPlan ? item("is-plan", "균형 계획선") : "",
|
||||
hasInitial ? item("is-initial", "적용될 초기 종단") : "",
|
||||
].join("")}</div>`;
|
||||
}
|
||||
|
||||
const CANDIDATE_COLUMNS: DataColumn[] = [
|
||||
|
||||
@@ -500,6 +500,38 @@
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* 적용될 초기 종단(476) — 종단곡선 포함 · [적용]하면 이 선이 작업본 · 초기값. */
|
||||
.b05-routerec__profile .is-initial {
|
||||
fill: none;
|
||||
stroke: var(--map-route, #f97316);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 3;
|
||||
}
|
||||
|
||||
.b05-routerec__plegend {
|
||||
display: flex;
|
||||
gap: var(--spacing-12);
|
||||
margin-top: var(--spacing-4, 4px);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routerec__plegend i {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
margin-right: var(--spacing-4, 4px);
|
||||
vertical-align: middle;
|
||||
border-top: 2px solid var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__plegend i.is-plan {
|
||||
border-top-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__plegend i.is-initial {
|
||||
border-top: 2px dashed var(--map-route, #f97316);
|
||||
}
|
||||
|
||||
/* 이 모달에서 셈한 후보 표 — 절 · 성 · 반입 · 최대 종단경사. */
|
||||
.b05-routerec__cands table {
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 476-2 추천 모달 「적용될 초기 종단」 헬퍼 — `_Initial_Model` · `_Recommend_Profile` 을 그 자리에서
|
||||
* 트랜스파일해 Node 로 돌려 서버 `initial_profile` 펴기 · 한 줄 · 그림 · 캐시 빼기를 JSON 한 벌로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
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;
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
|
||||
globalThis.fetch = () => Promise.resolve({ ok: false });
|
||||
|
||||
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
||||
|
||||
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.endsWith(".css")) return {};
|
||||
if (request.startsWith("@ui/"))
|
||||
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
|
||||
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 model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
|
||||
const prof = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend_Profile.ts"),
|
||||
);
|
||||
|
||||
// 476-1 응답 꼴(분석 2.3) — 결과 옆 `initial_profile`.
|
||||
const initialRaw = {
|
||||
fingerprint: "abc",
|
||||
with_profile: false,
|
||||
curve_length_m: 20,
|
||||
stations_m: [0, 20, 40],
|
||||
ground_m: [100, 103, 101],
|
||||
plan_m: [100, 102, 101],
|
||||
line: [
|
||||
[0, 100],
|
||||
[10, 101],
|
||||
[20, 102],
|
||||
[30, 101.5],
|
||||
[40, 101],
|
||||
],
|
||||
pvi_count: 3,
|
||||
curve_count: 1,
|
||||
straightened: 2,
|
||||
max_grade_pct: 10.04,
|
||||
warnings: [],
|
||||
seconds: 0.1,
|
||||
};
|
||||
const answer = {
|
||||
nodes: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
],
|
||||
curves: [],
|
||||
initial_profile: initialRaw,
|
||||
};
|
||||
const off = model.normalizeRouteInitial(answer, "follow", null, null, false);
|
||||
model.writeInitialChoice("p-476", off);
|
||||
const failed = model.normalizeRouteInitial(
|
||||
{ ...answer, initial_profile: { error: "지반고 종단 — 측점이 모자랍니다" } },
|
||||
"follow",
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
const balance = { stationsM: [0, 20, 40], groundM: [100, 103, 101], planM: [100, 101, 101] };
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
parsed: off.initialProfile,
|
||||
cached: model.readInitialChoice("p-476"),
|
||||
line: prof.initialLine(off.initialProfile),
|
||||
svgOff: prof.profileSvg(null, off.initialProfile),
|
||||
svgOn: prof.profileSvg(balance, off.initialProfile),
|
||||
svgOld: prof.profileSvg(balance),
|
||||
legendOff: prof.profileLegend(false, true),
|
||||
failed: failed.initialProfile,
|
||||
failedLine: prof.initialLine(failed.initialProfile),
|
||||
missing: model.normalizeRouteInitial({ ...answer, initial_profile: undefined }, "follow", null)
|
||||
.initialProfile,
|
||||
shortLine: prof.parseInitialProfile({ ...initialRaw, line: [[0, 100]] }),
|
||||
}),
|
||||
);
|
||||
@@ -43,7 +43,7 @@ def test_창_단추와_재계산() -> None:
|
||||
assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐
|
||||
assert 'rerun.addEventListener("click", () => void run(true));' in rec
|
||||
assert "const withProfile = profileOn;" in rec
|
||||
assert "params.onApply(preview);" in rec
|
||||
assert "params.onApply(picked);" in rec # 476 — 확인 창 뒤 미리 본 그 후보
|
||||
|
||||
|
||||
def test_요청과_적용에_종단_반영() -> None:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""476-2 추천 모달 — 「적용될 초기 종단」 선 · 한 줄 · [적용] 확인 글(요구 276).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_476_2_recommend_initial_line.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HELPER = Path(__file__).with_name("helper_476_2_initial_profile.cjs")
|
||||
|
||||
|
||||
def _out() -> dict:
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_펴기와_캐시_빼기() -> None:
|
||||
out = _out()
|
||||
p = out["parsed"]
|
||||
assert p["withProfile"] is False and p["curveLengthM"] == 20
|
||||
assert p["pviCount"] == 3 and p["curveCount"] == 1 and p["straightened"] == 2
|
||||
assert p["line"][3] == [30, 101.5] and p["planM"] == [100, 102, 101]
|
||||
assert "initialProfile" not in out["cached"] # 세션에 안 적음
|
||||
assert out["missing"] is None # 옛 서버 답
|
||||
assert out["shortLine"] is None # 선 점 하나 = 꼴 틀림
|
||||
|
||||
|
||||
def test_한_줄과_실패_글() -> None:
|
||||
out = _out()
|
||||
assert out["line"] == "초기 종단: 꼭짓점 3 · 종단곡선 1 · 직선화 2 · 최대 종단경사 10.0%(L 20)"
|
||||
assert out["failed"] == {"error": "지반고 종단 — 측점이 모자랍니다"}
|
||||
assert out["failedLine"].startswith("초기 종단을 셈하지 못했습니다 — 지반고 종단")
|
||||
|
||||
|
||||
def test_그림_선() -> None:
|
||||
out = _out()
|
||||
# 끔 = 지반선 + 초기 종단(계획선 빔)
|
||||
assert 'class="is-ground" d="M6.0 90.0L300.0 6.0L594.0 62.0"' in out["svgOff"]
|
||||
assert 'class="is-plan" d=""' in out["svgOff"]
|
||||
initial = 'class="is-initial" d="M6.0 90.0L153.0 62.0L300.0 34.0L447.0 48.0L594.0 62.0"'
|
||||
assert initial in out["svgOff"] and initial in out["svgOn"]
|
||||
assert 'class="is-plan" d="M6.0 90.0L300.0 62.0L594.0 62.0"' in out["svgOn"]
|
||||
assert "is-initial" not in out["svgOld"] # 옛 답 = 지반 · 균형만
|
||||
assert "is-plan" not in out["legendOff"] and "적용될 초기 종단" in out["legendOff"]
|
||||
|
||||
|
||||
def test_모달_배선과_확인_글() -> None:
|
||||
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
|
||||
assert '<div data-line="initial" hidden></div>' in rec
|
||||
assert "profileSvg(balance, initialShape)" in rec
|
||||
assert 'line("profile").hidden = !balance && !initialShape;' in rec
|
||||
apply = rec[rec.index('applyButton.addEventListener("click"') :][:400]
|
||||
assert 'showConfirmDialog(APPLY_CONFIRM, "적용")' in apply
|
||||
assert apply.index("showConfirmDialog") < apply.index("params.onApply(picked);")
|
||||
assert "종단 손 편집은 새 초기 종단으로 바뀝니다" in rec
|
||||
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
|
||||
assert ".b05-routerec__profile .is-initial" in css and "var(--map-route" in css
|
||||
Reference in New Issue
Block a user