From 58ba4545f203552ef492a812a33ac2369a4deaaf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 09:24:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=95=8C=EC=95=BD=20=EC=97=AC=EB=8D=9F(=EC=98=81?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EC=95=88=EC=A0=84=20=EC=9A=B0=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=EB=A5=98=20=ED=9A=A1=EB=8B=A8=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EA=B3=B5=EC=82=AC=EB=B9=84=20=EC=96=B4?= =?UTF-8?q?=EB=A6=BC=20=EC=B5=9C=EC=86=8C)=20=C2=B7=20=EC=98=81=EC=84=A0?= =?UTF-8?q?=20=EB=AA=A9=ED=91=9C=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EB=B9=84=EA=B5=90=EA=B0=92=20=EB=91=90=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20(PLAN=2023-5b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄) - 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임 - 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값) - 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷 Co-Authored-By: Claude Sonnet 5.5 --- B05_Profile/B05_Profile_Api_RouteInitial.ts | 4 +- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 88 ++++++++++++++--- .../B05_Profile_UI_RouteEdit_Recommend.ts | 43 +++++--- .../B05_Profile_UI_Style_RouteRecommend.css | 37 +++++-- .../tester/helper_b05_routeedit_initial.cjs | 77 ++++++++++++--- .../helper_b05_routeedit_initial_apply.cjs | 2 +- .../tester/test_b05_routeedit_initial.py | 97 ++++++++++++++----- .../test_b05_routeedit_initial_roundtrip.py | 21 +++- 8 files changed, 298 insertions(+), 71 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 4a658d9c..4fc9e802 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -16,7 +16,7 @@ const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; /** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */ export type RouteInitialLogic = - "follow" | "earthwork" | "gentle" | "short" | "balance" | "weighted"; + "follow" | "gradeline" | "earthwork" | "gentle" | "safe" | "crossings" | "cost" | "weighted"; /** 비중 계산의 세 비중(%) — 합 100. */ export interface RouteInitialWeights { @@ -32,6 +32,8 @@ export interface RouteInitialCriteria { min_radius_m?: number; stream_offset_m?: number; grade_smooth_m?: number; + /** 영선 노선(`gradeline`)의 목표 종단기울기(%) — 비우면 종단기울기 상한. */ + target_grade_pct?: number; } export interface RouteInitialRequest { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index d0578760..e60e958c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -17,10 +17,12 @@ type Vertex = [number, number]; export const LOGIC_LABEL: Record = { follow: "예상노선 추적", + gradeline: "영선 노선", earthwork: "토공 최소", gentle: "기울기 순한", - short: "짧은 노선", - balance: "흙 균형", + safe: "안전 우선", + crossings: "계류 횡단 최소", + cost: "공사비 어림 최소", weighted: "비중 계산", }; @@ -38,6 +40,12 @@ export const CRITERIA_KEYS = [ ] as const; export type CriteriaKey = (typeof CRITERIA_KEYS)[number]; +/** 영선 노선 목표 기울기 칸 — 공통 제약 네 칸과 따로(영선을 고를 때만 보이고 보낸다). */ +export const TARGET_GRADE_KEY = "target_grade_pct" as const; + +/** 겹침 값에 담길 수 있는 모든 키 — 캐시 · 서버 기억 견주기에 쓴다. */ +const OVERRIDE_KEYS = [...CRITERIA_KEYS, TARGET_GRADE_KEY] as const; + /** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */ export function rebalanceWeights( weights: RouteInitialWeights, @@ -122,6 +130,18 @@ export interface InitialMetrics { meanGradePct: number | null; cutM3: number | null; fillM3: number | null; + /** 옆경사(지형이 노선 옆으로 기운 정도, %) 평균 · 최대. */ + sideSlopeAvgPct: number | null; + sideSlopeMaxPct: number | null; + /** 위험 구간(옆경사 35° 이상 · 계류 버퍼 안)을 지나는 길이(m). */ + hazardM: number | null; + /** 계류를 건너는 횟수 · 계류와의 평균 이격(m). */ + streamCrossings: number | null; + streamOffsetAvgM: number | null; + /** 측점 기울기와 영선 목표 기울기 차의 평균(%p). */ + gradeDevPct: number | null; + /** 공사비 어림 — 절성토 · 연장 · 계류 횡단을 섞은 **상대값**(단위 없음 · 실제 공사비 아님). */ + costIndex: number | null; /** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */ surplusM3: number | null; overLimitCount: number | null; @@ -146,6 +166,8 @@ export interface InitialCriteria { streamOffsetM: number | null; /** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */ gradeSmoothM: number | null; + /** 영선 노선이 실제로 쓴 목표 기울기(%) — 서버 `target_grade_pct`. */ + targetGradePct: number | null; } /** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */ @@ -284,6 +306,13 @@ export function normalizeRouteInitial( meanGradePct: pick(m, "avg_grade_pct"), cutM3: pick(m, "cut_m3"), fillM3: pick(m, "fill_m3"), + sideSlopeAvgPct: pick(m, "side_slope_avg_pct"), + sideSlopeMaxPct: pick(m, "side_slope_max_pct"), + hazardM: pick(m, "hazard_m"), + streamCrossings: pick(m, "stream_crossings"), + streamOffsetAvgM: pick(m, "stream_offset_avg_m"), + gradeDevPct: pick(m, "grade_dev_pct"), + costIndex: pick(m, "cost_index"), surplusM3: pick(m, "surplus_m3"), overLimitCount: pick(over, "total"), offsetM: pick(m, "offset_avg_m"), @@ -307,6 +336,7 @@ export function normalizeRouteInitial( minRadiusM: pick(c, "min_radius_m"), streamOffsetM: pick(c, "stream_offset_m"), gradeSmoothM: pick(c, "grade_smooth_m"), + targetGradePct: pick(c, "target_grade_pct"), } : null, criteriaOverrides, @@ -327,7 +357,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null { if (!raw || typeof raw !== "object") return null; const source = raw as Record; const out: RouteInitialCriteria = {}; - for (const key of CRITERIA_KEYS) { + for (const key of OVERRIDE_KEYS) { const value = num(source[key]); if (value !== null) out[key] = value; } @@ -336,7 +366,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null { /** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */ function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean { - return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null)); + return OVERRIDE_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null)); } /** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추적. */ @@ -414,27 +444,61 @@ export function similarLine(choice: InitialChoice | null): string { export const SIMILAR_HINT = "기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈)."; -/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */ -export function metricsLine(metrics: InitialMetrics | null): string { - if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다."; - return [ +/** 결과 비교값 — 두 묶음. 노선 · 기울기 / 흙 · 안전 · 비용. 서버가 안 준 칸(옛 답)은 빠진다. */ +export interface MetricsLines { + route: string; + work: string; +} + +const NO_METRICS = "비교값 — 알약을 누르면 서버가 셈해 보입니다."; + +export function metricsLines(metrics: InitialMetrics | null): MetricsLines { + if (!metrics) return { route: NO_METRICS, work: "" }; + const has = (value: number | null): value is number => value !== null; + const route = [ `연장 ${fixed(metrics.lengthM, 1, "m")}`, `기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`, - `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, - ...(metrics.surplusM3 === null ? [] : [`남는 흙 ${surplusText(metrics.surplusM3)}`]), + ...(has(metrics.gradeDevPct) + ? [`목표 기울기 편차 ${fixed(metrics.gradeDevPct, 1, "%p")}`] + : []), + ...(has(metrics.sideSlopeAvgPct) + ? [ + `옆경사 평균 ${fixed(metrics.sideSlopeAvgPct, 1, "%")} · 최대 ${fixed(metrics.sideSlopeMaxPct, 1, "%")}`, + ] + : []), `기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`, - `이격 ${fixed(metrics.offsetM, 1, "m")}`, + `예상노선과 이격 ${fixed(metrics.offsetM, 1, "m")}`, ].join(" · "); + const work = [ + `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + ...(has(metrics.surplusM3) ? [`남는 흙 ${surplusText(metrics.surplusM3)}`] : []), + ...(has(metrics.hazardM) ? [`위험 구간 통과 ${fixed(metrics.hazardM, 0, "m")}`] : []), + ...(has(metrics.streamCrossings) ? [`계류 횡단 ${metrics.streamCrossings}회`] : []), + ...(has(metrics.streamOffsetAvgM) + ? [`계류 이격 평균 ${fixed(metrics.streamOffsetAvgM, 1, "m")}`] + : []), + ...(has(metrics.costIndex) + ? [`공사비 어림 ${fixed(metrics.costIndex, 0, "")}(어림 · 상대값)`] + : []), + ].join(" · "); + return { route, work }; } /** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */ -export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string { +export function criteriaLine( + criteria: InitialCriteria | null, + planMinRadiusM: number, + /** 영선 노선일 때만 — 서버가 쓴 목표 기울기를 함께 보인다. */ + showTarget = false, +): string { const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null); const parts = [ `종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`, `최소 R ${fixed(radius, 0, "m")}`, `계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`, ]; + if (showTarget && criteria?.targetGradePct != null) + parts.push(`영선 목표 기울기 ${fixed(criteria.targetGradePct, 1, "%")}`); // 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다. if (criteria?.gradeSmoothM) parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index e78d004b..6f728f9d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -23,7 +23,8 @@ import { criteriaLine, CRITERIA_KEYS, LOGIC_LABEL, - metricsLine, + metricsLines, + TARGET_GRADE_KEY, canLockWeight, derivedWeightKey, inputWeight, @@ -48,10 +49,12 @@ type Vertex = [number, number]; const PILLS: RouteInitialLogic[] = [ "follow", + "gradeline", "earthwork", "gentle", - "short", - "balance", + "safe", + "crossings", + "cost", "weighted", ]; @@ -59,8 +62,10 @@ const PILL_TITLE: Record = { follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다", earthwork: "절토 · 성토가 가장 적은 노선", gentle: "종단기울기가 가장 순한 노선", - short: "연장이 가장 짧은 노선(제약 지킴)", - balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선", + gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) — 목표 기울기를 넣을 수 있음", + safe: "급한 옆경사 · 계류 가까운 곳을 피하는 노선", + crossings: "계류를 건너는 횟수가 가장 적은 노선", + cost: "토공 + 연장 + 계류 횡단을 섞은 공사비 어림(상대값)이 가장 낮은 노선", weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선", }; @@ -200,11 +205,16 @@ export function openRecommendModal(params: RecommendParams): void { data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}`, ).join("")} +
+
@@ -228,7 +238,8 @@ export function openRecommendModal(params: RecommendParams): void { const criteriaInputs = body.querySelectorAll("[data-criteria]"); criteriaInputs.forEach((input) => { - const value = params.criteria?.[input.dataset.criteria as CriteriaKey]; + const value = + params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY]; input.value = value === undefined ? "" : String(value); }); @@ -236,10 +247,13 @@ export function openRecommendModal(params: RecommendParams): void { function currentCriteria(): RouteInitialCriteria { const out: RouteInitialCriteria = {}; criteriaInputs.forEach((input) => { + const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY; + // 목표 기울기는 영선 노선을 물을 때만 보낸다. + if (key === TARGET_GRADE_KEY && logic !== "gradeline") return; const raw = input.value.trim(); if (raw === "") return; const value = Number(raw); - if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value; + if (Number.isFinite(value)) out[key] = value; }); return out; } @@ -290,19 +304,26 @@ export function openRecommendModal(params: RecommendParams): void { q("[data-weight-sum]").textContent = `합 ${weights.expected + weights.earthwork + weights.grade}`; criteriaInputs.forEach((input) => { - const key = input.dataset.criteria as CriteriaKey; + const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY; + // 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시. + const known = + key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key]; input.placeholder = - params.defaults[key] !== undefined ? String(params.defaults[key]) : "기본값"; + known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값"; input.disabled = busy; }); + q("[data-target-row]").hidden = logic !== "gradeline"; rerun.hidden = !dirty; rerun.disabled = busy || invalid.size > 0; applyButton.disabled = busy || dirty || !preview || invalid.size > 0; + const shown = metricsLines(preview?.metrics ?? null); line("metrics").textContent = preview ? preview.metrics - ? metricsLine(preview.metrics) + ? shown.route : "비교값 — 서버 답에 없습니다." : "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다."; + line("metrics-work").textContent = preview ? shown.work : ""; + line("metrics-work").hidden = !line("metrics-work").textContent; const violations = violationsLine(preview?.metrics ?? null); line("violations").textContent = violations; line("violations").title = (preview?.metrics?.violations ?? []) @@ -318,7 +339,7 @@ export function openRecommendModal(params: RecommendParams): void { line("similar-hint").textContent = similar ? SIMILAR_HINT : ""; line("similar-hint").hidden = !similar; line("criteria").textContent = preview - ? criteriaLine(preview.criteria, params.planMinRadiusM) + ? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline") : ""; line("criteria").hidden = !preview; drawMap(); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 60b2de8d..a3895d00 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -26,14 +26,32 @@ } /* 알약 — 하나만 고른 상태(라디오 묶음). */ +/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */ .b05-routerec__pills { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-8); } +/* 좁은 폭 — 두 줄을 지키려 글자를 줄이고, 글자가 잘릴 만큼 좁으면(560px 이하) 두 칸 네 줄. */ +@media (max-width: 720px) { + .b05-routerec__pill { + padding-inline: 4px; + font-size: var(--text-body-sm); + } +} + +@media (max-width: 560px) { + .b05-routerec__pills { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .b05-routerec__pill { - padding: 8px var(--spacing-16); + padding: 8px var(--spacing-8); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-surface); @@ -245,7 +263,8 @@ gap: var(--spacing-8); } -.b05-routerec__criteria label { +.b05-routerec__criteria label, +.b05-routerec__target { display: grid; grid-template-columns: minmax(0, 1fr) 5.5em 1.5em; align-items: center; @@ -253,7 +272,8 @@ color: var(--color-text-body); } -.b05-routerec__criteria input { +.b05-routerec__criteria input, +.b05-routerec__target input { width: 100%; padding: 4px 8px; border: 1px solid var(--color-border); @@ -264,7 +284,8 @@ text-align: right; } -.b05-routerec__criteria input::placeholder { +.b05-routerec__criteria input::placeholder, +.b05-routerec__target input::placeholder { color: var(--color-text-secondary); } @@ -318,3 +339,7 @@ height: 300px; } } + +.b05-routerec__target[hidden] { + display: none; +} diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index bada657b..2e8aca0c 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -162,8 +162,8 @@ try { const labels = { follow: model.choiceLabel("follow", null), weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, grade: 20 }), - metrics: model.metricsLine(plain.metrics), - metricsNone: model.metricsLine(null), + metrics: model.metricsLines(plain.metrics), + metricsNone: model.metricsLines(null).route, criteria: model.criteriaLine(plain.criteria, 0), criteriaFallback: model.criteriaLine(null, 15), violations: model.violationsLine(plain.metrics), @@ -298,21 +298,58 @@ const weightLocks = { }, }; -// ⑨ 추천 갈래 여섯 · 남는 흙 · 같은 노선 알림(계획서 23-4c 화면). -const withExtras = (extra, metricExtra = {}) => +// ⑨ 추천 갈래 여덟 · 새 비교값 · 영선 목표 기울기 · 같은 노선 알림(계획서 23-5b 화면). +const newMetrics = { + side_slope_avg_pct: 65.8, + side_slope_max_pct: 104.1, + hazard_m: 458.4, + stream_crossings: 0, + stream_offset_avg_m: 312.44, + grade_dev_pct: 8.53, + cost_index: 9691.4, + surplus_m3: -578.2, +}; +const withExtras = (extra, metricExtra = {}, logic = "gradeline") => model.normalizeRouteInitial( { ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } }, - "short", + logic, null, ); +const gradeline = withExtras( + { criteria: { ...plainResponse.criteria, target_grade_pct: 12 } }, + newMetrics, +); const sixth = { labels: Object.fromEntries( - ["follow", "earthwork", "gentle", "short", "balance", "weighted"].map((k) => [k, model.LOGIC_LABEL[k]]), + ["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map((k) => [ + k, + model.LOGIC_LABEL[k], + ]), ), + removedLogics: ["short", "balance"].map((k) => k in model.LOGIC_LABEL), + // 옛 저장값(short · balance)은 예상노선 추적으로 읽는다 — 서버 기억 · 캐시 둘 다. + legacyBase: ["short", "balance"].map((k) => model.serverBase({ logic: k })), + legacyCache: undefined, // 아래 ④ 캐시 왕복 뒤에 채운다(저장소를 같이 쓰므로) surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)), - metricsPlus: model.metricsLine(withExtras({}, { surplus_m3: 122.4 }).metrics), - metricsMinus: model.metricsLine(withExtras({}, { surplus_m3: -578 }).metrics), - metricsNone: model.metricsLine(withExtras({}).metrics), + groups: model.metricsLines(gradeline.metrics), + groupsMinus: model.metricsLines(withExtras({}, { surplus_m3: -578 }).metrics), + groupsOld: model.metricsLines(withExtras({}).metrics), + criteriaTarget: model.criteriaLine(gradeline.criteria, 0, true), + criteriaNoTarget: model.criteriaLine(gradeline.criteria, 0, false), + targetParsed: gradeline.criteria.targetGradePct, + targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 }) + .criteriaOverrides, + targetBase: model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }), + sameTarget: [ + model.sameBase( + model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 }), + model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }), + ), + model.sameBase( + model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 12 }), + model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }), + ), + ], similarTwo: model.similarLine( withExtras({ band_width_m: 13.5, @@ -323,13 +360,19 @@ const sixth = { }), ), similarOne: model.similarLine( - withExtras({ band_width_m: 13.54, similar: [{ logic: "balance", max_gap_m: 6.1 }] }), + withExtras({ band_width_m: 13.54, similar: [{ logic: "safe", max_gap_m: 6.1 }] }), ), - similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] })), + similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "cost", max_gap_m: 9.1 }] })), similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })), similarMissing: model.similarLine(withExtras({})), similarNull: model.similarLine(null), - similarUnknown: withExtras({ similar: [{ logic: "nope", max_gap_m: 1 }, { logic: "short", max_gap_m: "x" }] }).similar, + similarUnknown: withExtras({ + similar: [ + { logic: "nope", max_gap_m: 1 }, + { logic: "short", max_gap_m: 2 }, + { logic: "cost", max_gap_m: "x" }, + ], + }).similar, bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM, hint: model.SIMILAR_HINT, weightedViolation: model.violationsLine({ @@ -351,8 +394,6 @@ const sixth = { limit: 14, reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음", }), - shortLabel: model.choiceLabel("short", { expected: 0, earthwork: 0, grade: 0 }), - balanceLabel: model.choiceLabel("balance", null), }; // ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. @@ -378,6 +419,14 @@ const badLogic = model.readInitialChoice("p-1"); model.writeInitialChoice("p-1", null); const cleared = model.readInitialChoice("p-1"); +// 옛 저장값(short)이 캐시에 있어도 읽지 않는다 — 서버 기억(추적)을 쓴다. +session._map.clear(); +session.setItem( + "aislo:result:routeedit-initial:p-9", + JSON.stringify({ logic: "short", weights: null, snapshot: { planned: [[0, 0], [1, 1]] } }), +); +sixth.legacyCache = model.readInitialChoice("p-9"); + process.stdout.write( JSON.stringify({ moves, diff --git a/resources/tester/helper_b05_routeedit_initial_apply.cjs b/resources/tester/helper_b05_routeedit_initial_apply.cjs index c7e59008..a1372bef 100644 --- a/resources/tester/helper_b05_routeedit_initial_apply.cjs +++ b/resources/tester/helper_b05_routeedit_initial_apply.cjs @@ -81,7 +81,7 @@ const out = jobs.map((job) => { }, }, label: model.choiceLabel(choice.logic, choice.weights), - metrics: model.metricsLine(choice.metrics), + metrics: model.metricsLines(choice.metrics), violations: model.violationsLine(choice.metrics), relaxed: model.relaxedLine(choice), criteria: model.criteriaLine(choice.criteria, 0), diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index ece139ab..f5b9c444 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -77,11 +77,12 @@ def test_비교값과_기준값_글(results): labels = results["labels"] assert labels["follow"] == "예상노선 추적" assert labels["weighted"] == "비중 계산 60/20/20" - assert labels["metrics"] == ( - "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥" - " · 기준 초과 2구간 · 이격 3.3m" - ) - assert "로직을 누르면" in labels["metricsNone"] + # 두 묶음 — 노선 · 기울기 / 흙 · 안전 · 비용(서버가 안 준 새 칸은 빠짐) + assert labels["metrics"] == { + "route": "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 기준 초과 2구간 · 예상노선과 이격 3.3m", + "work": "절토 1,500㎥ · 성토 900㎥", + } + assert "알약을 누르면" in labels["metricsNone"] # 기준값 확정(2026-09-29) — 「후보 · 사용자 확정 대기」 꼬리표가 더는 안 붙는다. assert labels["criteria"] == ( "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름" @@ -248,36 +249,88 @@ def test_비중_값_칸_직접_입력(results): } -def test_추천_갈래_여섯_이름과_가중치_없음(results): +def test_추천_갈래_여덟_이름_알약_순서(results): six = results["sixth"] assert six["labels"] == { "follow": "예상노선 추적", + "gradeline": "영선 노선", "earthwork": "토공 최소", "gentle": "기울기 순한", - "short": "짧은 노선", - "balance": "흙 균형", + "safe": "안전 우선", + "crossings": "계류 횡단 최소", + "cost": "공사비 어림 최소", "weighted": "비중 계산", } - # 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만) - assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형" + # 짧은 노선 · 흙 균형은 뺐다 + assert six["removedLogics"] == [False, False] page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts") with open(page, encoding="utf-8") as handle: text = handle.read() + start = text.index("const PILLS") + pills = text[start : text.index("];", start)] order = [ - text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance") + pills.index(f'"{name}"') + for name in ( + "follow", + "gradeline", + "earthwork", + "gentle", + "safe", + "crossings", + "cost", + "weighted", + ) ] - assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중 + assert order == sorted(order) # 알약 순서 + assert '"short"' not in pills and '"balance"' not in pills + assert ( + "grid-template-columns: repeat(4" + in open( + os.path.join( + HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Style_RouteRecommend.css" + ), + encoding="utf-8", + ).read() + ) # 네 칸 두 줄 -def test_비교값_남는_흙(results): +def test_옛_저장값_short_balance는_추적으로_읽음(results): + six = results["sixth"] + assert six["legacyBase"] == [ + {"logic": "follow", "weights": None, "criteria": None}, + {"logic": "follow", "weights": None, "criteria": None}, + ] + assert six["legacyCache"] is None # 캐시의 옛 갈래는 버림 → 서버 기억(추적)을 씀 + + +def test_비교값_새_칸_두_묶음(results): six = results["sixth"] assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"] - assert six["metricsPlus"].endswith( - "성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m" + groups = six["groups"] + assert groups["route"] == ( + "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 목표 기울기 편차 8.5%p" + " · 옆경사 평균 65.8% · 최대 104.1% · 기준 초과 2구간 · 예상노선과 이격 3.3m" ) - assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"] - # 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로 - assert "남는 흙" not in six["metricsNone"] + assert groups["work"] == ( + "절토 1,500㎥ · 성토 900㎥ · 남는 흙 −578㎥(모자람) · 위험 구간 통과 458m" + " · 계류 횡단 0회 · 계류 이격 평균 312.4m · 공사비 어림 9,691(어림 · 상대값)" + ) + assert "남는 흙 −578㎥(모자람)" in six["groupsMinus"]["work"] + # 서버가 안 준 칸(옛 답)은 빠진다 + assert "옆경사" not in six["groupsOld"]["route"] and "공사비" not in six["groupsOld"]["work"] + + +def test_영선_목표_기울기(results): + six = results["sixth"] + assert six["targetParsed"] == 12 + assert six["criteriaTarget"].endswith("영선 목표 기울기 12.0% · 기울기 잴 지반 5m 고름") or ( + "영선 목표 기울기 12.0%" in six["criteriaTarget"] + ) + assert "영선 목표" not in six["criteriaNoTarget"] # 영선이 아니면 안 보임 + # 요청에 실은 값은 겹침으로 남고 · 서버 기억과 견줘 다르면 캐시를 버림 + assert six["targetOverride"] == {"target_grade_pct": 10} + assert six["targetBase"]["criteria"] == {"target_grade_pct": 10} + assert six["sameTarget"] == [True, False] def test_같은_노선_알림_줄(results): @@ -287,13 +340,13 @@ def test_같은_노선_알림_줄(results): "토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)" ) assert six["similarOne"] == ( - "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)" + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 안전 우선과 거의 같은 노선(가장 먼 거리 6.1m)" ) - # 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」 - assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"] + # 띠 폭이 없어도 줄은 선다 · 받침 있는 이름은 「과」 + assert "폭" not in six["similarNoBand"] and "공사비 어림 최소와 거의" in six["similarNoBand"] # 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음 assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == "" - assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목은 버림 + assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목(옛 short 포함)은 버림 assert six["bandParsed"] == 13.5 assert "제약 값을 바꾸면" in six["hint"] diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py index 0b77115d..f30b98b3 100644 --- a/resources/tester/test_b05_routeedit_initial_roundtrip.py +++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py @@ -43,9 +43,11 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [ ("follow", None), ("earthwork", None), ("gentle", None), - # PLAN 23-5 — 서버는 짧은 노선 · 흙 균형을 빼고 넷(gradeline · safe · crossings · cost)을 - # 더함. 새 넷은 화면(23-5b)이 알약으로 알게 되면 여기 더함 — 지금 화면은 다시 열 때 모르는 - # 갈래를 추적으로 읽음. 서버 쪽 저장 · 다시 읽기는 `test_route_initial` 이 지킴. + # PLAN 23-5 — 새 갈래 넷(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소)도 같은 왕복. + ("gradeline", None), + ("safe", None), + ("crossings", None), + ("cost", None), ("weighted", {"expected": 50, "earthwork": 25, "grade": 25}), ] @@ -202,7 +204,18 @@ def test_비교값이_응답에_다_있음(rounds): assert isinstance(metrics["over_limit"]["total"], int) assert isinstance(metrics["offset_avg_m"], (int, float)) # 화면 한 줄에 빈 칸(—)이 없다. - assert "—" not in item["applied"]["metrics"], item["applied"]["metrics"] + shown = item["applied"]["metrics"] + assert "—" not in shown["route"] + shown["work"], shown + # 새 비교값 칸도 화면 글에 다 있음(계획서 23-5) + for label in ( + "옆경사 평균", + "목표 기울기 편차", + "위험 구간 통과", + "계류 횡단", + "계류 이격 평균", + "공사비 어림", + ): + assert label in shown["route"] + shown["work"], (item["logic"], label) def test_제약_못_지킨_구간이_목록과_화면_줄에(rounds):