feat(B05): 노선 추천 알약 여덟(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소) · 영선 목표 기울기 칸 · 비교값 두 묶음 (PLAN 23-5b)

- 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄)
- 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임
- 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값)
- 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:24:38 +09:00
co-authored by Claude Sonnet 5.5
parent 4d7a978d0b
commit 58ba4545f2
8 changed files with 298 additions and 71 deletions
+3 -1
View File
@@ -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 {
@@ -17,10 +17,12 @@ type Vertex = [number, number];
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
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<string, unknown>;
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")} 고름`);
@@ -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<RouteInitialLogic, string> = {
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}</label>`,
).join("")}
</div>
<label class="b05-routerec__target" data-target-row hidden
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
<input type="number" inputmode="decimal" step="0.1" min="0" max="100"
data-criteria="${TARGET_GRADE_KEY}" placeholder="상한" />%</label>
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
</div>
</div>
<div class="b05-routerec__lines">
<div data-line="metrics"></div>
<div data-line="metrics-work"></div>
<div data-line="violations" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div data-line="similar" hidden></div>
@@ -228,7 +238,8 @@ export function openRecommendModal(params: RecommendParams): void {
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[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<HTMLElement>("[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<HTMLElement>("[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();
@@ -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;
}
@@ -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,
@@ -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),
+75 -22
View File
@@ -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"]
@@ -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):