feat(B05): 노선 추천 — 알약 여섯(짧은 노선 · 흙 균형) · 남는 흙 비교값 · 같은 노선 알림 줄 · 비중 고정 시험 (PLAN 23-4c 화면 · 26-2c)

- 알약: 예상노선 추적 · 토공 최소 · 기울기 순한 · 짧은 노선(short) · 흙 균형(balance) · 비중 계산 — 짧은 노선 · 흙 균형은 비중 없음
- 비교값에 「남는 흙」(surplus_m3 · + 남음 / − 모자람)
- 응답 similar · band_width_m → 비교값 아래 「이 지형은 제약 안에서 갈 길이 폭 …」 한 줄 + 흐린 안내 · 없으면 줄 숨김
- 시험: 가짜 응답 · 여섯 이름 · 남는 흙 · 알림 줄 · 비중 계산 사유 · 왕복 시험에 short · balance 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 08:10:09 +09:00
co-authored by Claude Sonnet 5.5
parent a66df6d887
commit 2deef5a5da
7 changed files with 208 additions and 2 deletions
@@ -298,6 +298,63 @@ const weightLocks = {
},
};
// ⑨ 추천 갈래 여섯 · 남는 흙 · 같은 노선 알림(계획서 23-4c 화면).
const withExtras = (extra, metricExtra = {}) =>
model.normalizeRouteInitial(
{ ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } },
"short",
null,
);
const sixth = {
labels: Object.fromEntries(
["follow", "earthwork", "gentle", "short", "balance", "weighted"].map((k) => [k, model.LOGIC_LABEL[k]]),
),
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),
similarTwo: model.similarLine(
withExtras({
band_width_m: 13.5,
similar: [
{ logic: "earthwork", max_gap_m: 0.0 },
{ logic: "gentle", max_gap_m: 6.1 },
],
}),
),
similarOne: model.similarLine(
withExtras({ band_width_m: 13.54, similar: [{ logic: "balance", max_gap_m: 6.1 }] }),
),
similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "weighted", 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,
bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM,
hint: model.SIMILAR_HINT,
weightedViolation: model.violationsLine({
...plain.metrics,
violations: [
{
kind: "grade",
station: "No.9+0.0",
value: 29.1,
limit: 14,
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)",
},
],
}),
weightedViolationReason: model.violationText({
kind: "grade",
station: "No.9+0.0",
value: 29.1,
limit: 14,
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음",
}),
shortLabel: model.choiceLabel("short", { expected: 0, earthwork: 0, grade: 0 }),
balanceLabel: model.choiceLabel("balance", null),
};
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
@@ -341,5 +398,6 @@ process.stdout.write(
historyRecs,
sweep,
weightLocks,
sixth,
}),
);
@@ -246,3 +246,59 @@ def test_비중_값_칸_직접_입력(results):
"ok": False,
"weights": {"expected": 70, "earthwork": 20, "grade": 10},
}
def test_추천_갈래_여섯_이름과_가중치_없음(results):
six = results["sixth"]
assert six["labels"] == {
"follow": "예상노선 추적",
"earthwork": "토공 최소",
"gentle": "기울기 순한",
"short": "짧은 노선",
"balance": "흙 균형",
"weighted": "비중 계산",
}
# 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만)
assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형"
page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts")
with open(page, encoding="utf-8") as handle:
text = handle.read()
order = [
text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance")
]
assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중
def test_비교값_남는_흙(results):
six = results["sixth"]
assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"]
assert six["metricsPlus"].endswith(
"성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m"
)
assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"]
# 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로
assert "남는 흙" not in six["metricsNone"]
def test_같은_노선_알림_줄(results):
six = results["sixth"]
assert six["similarTwo"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — "
"토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)"
)
assert six["similarOne"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)"
)
# 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」
assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"]
# 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음
assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == ""
assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목은 버림
assert six["bandParsed"] == 13.5
assert "제약 값을 바꾸면" in six["hint"]
def test_비중_계산_넘은_구간_사유_그대로(results):
six = results["sixth"]
assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)"
assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)"
@@ -43,6 +43,8 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [
("follow", None),
("earthwork", None),
("gentle", None),
("short", None),
("balance", None),
("weighted", {"expected": 50, "earthwork": 25, "grade": 25}),
]