From 8229a5449bd7bef2d0311a4bad9dde36df356c46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 13:12:18 +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=B0=BD):=20=EB=AA=BB=20=EC=A7=80=ED=82=A8=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=3D=20R=20=C2=B7=20=EA=B3=84=EB=A5=98=20?= =?UTF-8?q?=C2=B7=20=EC=B8=A1=EC=A0=90=EB=A7=8C=20=C2=B7=20=EC=9B=90?= =?UTF-8?q?=EC=A7=80=EB=B0=98=20=EA=B8=B0=EC=9A=B8=EA=B8=B0(=EC=B0=B8?= =?UTF-8?q?=EA=B3=A0)=20=EC=A4=84=20=C2=B7=20=EC=8B=9C=EC=A2=85=EC=A0=90?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=20=EC=B0=A8=20=EC=95=8C=EB=A6=BC=20(421-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「제약 못 지킨 구간」 · 「기준 초과」 = 같은 목록에서 원지반 기울기 뺀 수(엔진 419 짝) - 「원지반 기울기(참고) 최대 … % · 상한 넘는 곳 N」 줄 따로 · 넘는 곳은 풍선 - ends_over_limit 이면 「시종점 높이 차가 상한을 넘음(평균 … %) — 종단으로 못 맞춤 · 노선을 늘려야 함」 - 도움말 글(종단기울기 상한 · 못 지킨 구간 · 원지반 기울기 · 시종점) · 시험 test_421_2 · 옛 시험 단언 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 43 ++++++- .../B05_Profile_UI_RouteEdit_Recommend.ts | 16 ++- ...B05_Profile_UI_RouteEdit_Recommend_Help.ts | 17 ++- .../tester/helper_421_2_violation_lines.cjs | 107 ++++++++++++++++++ .../tester/helper_b05_routeedit_initial.cjs | 9 +- .../tester/test_421_2_violation_lines.py | 61 ++++++++++ .../tester/test_b05_routeedit_initial.py | 10 +- 7 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 resources/tester/helper_421_2_violation_lines.cjs create mode 100644 resources/tester/test_421_2_violation_lines.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index daef238a2..bfc290474 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -160,7 +160,7 @@ export interface InitialMetrics { violations: InitialViolation[] | null; } -/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */ +/** 서버 목록 한 줄 — 곡선반지름 · 계류 이격 · 측점 밖(못 지킴) · 원지반 기울기(`grade` · 참고만 · 419). */ export interface InitialViolation { kind: string; station: string; @@ -205,6 +205,9 @@ export interface InitialChoice { offStationCurves?: number | null; /** 추천 창 「종단 반영」 값(421) — [적용] 균형 종단 · 다시 열 때 단추. 옛 캐시면 없음. */ withProfile?: boolean; + /** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */ + endsGradePct?: number | null; + endsOverLimit?: boolean; } /** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ @@ -328,7 +331,12 @@ export function normalizeRouteInitial( gradeDevPct: pick(m, "grade_dev_pct"), costIndex: pick(m, "cost_index"), surplusM3: pick(m, "surplus_m3"), - overLimitCount: pick(over, "total"), + // 「못 지킨 구간」과 같은 목록에서 셈(원지반 기울기 뺌 · 421-2) — 목록 없는 옛 답만 서버 수 + overLimitCount: Array.isArray(m.violations) + ? (m.violations as Array>).filter( + (item) => item.kind !== "grade", + ).length + : pick(over, "total"), offsetM: pick(m, "offset_avg_m"), violations: Array.isArray(m.violations) ? (m.violations as Array>).map((item) => ({ @@ -346,6 +354,8 @@ export function normalizeRouteInitial( bandWidthM: num(body.band_width_m), stored: body.stored === true, offStationCurves: num(body.off_station_curves), + endsGradePct: num(body.ends_grade_pct), + endsOverLimit: body.ends_over_limit === true, // 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값 withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile, balance: parseBalance(m.profile), @@ -555,9 +565,17 @@ export function violationText(item: InitialViolation): string { return `${item.station} ${value}${head ? `(${head})` : ""}`; } +/** 원지반 기울기 — 참고만(419 · 계획선 높이로 푸는 값) · 「못 지킨 구간」 · 「기준 초과」에 안 듦. */ +const isGroundGrade = (item: InitialViolation): boolean => item.kind === "grade"; + +/** 제약 못 지킨 구간(R · 계류 · 측점) — 서버 목록에서 원지반 기울기를 뺀 것. 옛 답(목록 없음)이면 null. */ +export function brokenItems(metrics: InitialMetrics | null): InitialViolation[] | null { + return metrics?.violations?.filter((item) => !isGroundGrade(item)) ?? null; +} + /** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */ export function violationsLine(metrics: InitialMetrics | null): string { - const items = metrics?.violations; + const items = brokenItems(metrics); if (!items) return ""; if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴"; const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · "); @@ -565,6 +583,25 @@ export function violationsLine(metrics: InitialMetrics | null): string { return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`; } +/** 원지반 기울기(참고) 한 줄 — 최대 · 상한 넘는 곳 수. 서버가 목록을 안 줬으면 빈 글. */ +export function groundGradeLine(metrics: InitialMetrics | null): string { + const items = metrics?.violations; + if (!items) return ""; + const over = items.filter(isGroundGrade).length; + return `원지반 기울기(참고) 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 상한 넘는 곳 ${over}`; +} + +/** 원지반 기울기 넘는 곳 전부 — 참고 줄 풍선. */ +export function groundGradeTitle(metrics: InitialMetrics | null): string { + return (metrics?.violations ?? []).filter(isGroundGrade).map(violationText).join("\n"); +} + +/** 시종점 높이 차가 상한을 넘을 때만 — 종단으로 못 맞춤(노선을 늘려야 함 · 419). 아니면 빈 글. */ +export function endsLine(choice: InitialChoice | null): string { + if (!choice?.endsOverLimit) return ""; + return `시종점 높이 차가 상한을 넘음(평균 ${fixed(choice.endsGradePct ?? null, 1, "%")}) — 종단으로 못 맞춤 · 노선을 늘려야 함`; +} + /** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */ export function relaxedLine(choice: InitialChoice | null): string { const bound = choice?.terrainBoundPct; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 831ecfb20..0c46b03b1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -39,6 +39,10 @@ import { SIMILAR_HINT, similarLine, violationsLine, + brokenItems, + endsLine, + groundGradeLine, + groundGradeTitle, violationText, WEIGHT_KEYS, type CriteriaKey, @@ -219,6 +223,8 @@ export function openRecommendModal(params: RecommendParams): void { + + @@ -423,10 +429,18 @@ export function openRecommendModal(params: RecommendParams): void { line("candidates").hidden = seen.size === 0; const violations = violationsLine(view?.metrics ?? null); line("violations").textContent = violations; - line("violations").title = (view?.metrics?.violations ?? []) + line("violations").title = (brokenItems(view?.metrics ?? null) ?? []) .map((item) => `${violationText(item)} — ${item.reason}`) .join("\n"); line("violations").hidden = !violations; + // 원지반 기울기 = 참고 줄 따로 · 시종점 높이 차 알림(421-2 · 엔진 419) + const ground = groundGradeLine(view?.metrics ?? null); + line("ground").textContent = ground; + line("ground").title = groundGradeTitle(view?.metrics ?? null); + line("ground").hidden = !ground; + const ends = endsLine(view); + line("ends").textContent = ends; + line("ends").hidden = !ends; const relaxed = relaxedLine(view); line("relaxed").textContent = relaxed; line("relaxed").hidden = !relaxed; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index 53ef1c562..9fa47c008 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -31,10 +31,11 @@ const LOGIC_HELP: Record = { how: [ "새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음", "전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤", - "종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음", + "계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음 · 원지반 기울기는 참고 줄", ], when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴", - caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인", + caution: + "계류 이격 · 기울기를 보장하지 않음 — 못 지킨 구간 · 원지반 기울기(참고) · 시종점 알림을 꼭 확인", profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고", }, gradeline: { @@ -87,7 +88,7 @@ const CRITERIA_HELP: Array<{ name: string; text: string }> = [ }, { name: "종단기울기 상한(%)", - text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음", + text: "지반이 이보다 가파른 쪽으로 곧장 오르는 길은 찾지 않음. 측점(20m) 사이 원지반 기울기는 참고만 — 계획선 높이(종단)로 푸는 값이라 「못 지킨 구간」에 안 듦. 시종점 높이 차가 상한을 넘으면 종단으로도 못 맞춰 알림. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%)", }, { name: "최소 반지름(m)", @@ -118,7 +119,15 @@ const LINES_HELP: Array<{ name: string; text: string }> = [ }, { name: "못 지킨 구간", - text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)", + text: "최소 반지름 · 계류 이격 · 측점 밖 곡선을 넘은 곳만(「기준 초과」 수와 같음 · 5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)", + }, + { + name: "원지반 기울기(참고)", + text: "측점(20m) 사이 원지반 기울기 최대 · 상한 넘는 곳 수 — 종단으로 푸는 값이라 판정에 안 듦(넘는 곳은 마우스를 올리면 보임)", + }, + { + name: "시종점 높이 차", + text: "시점 · 종점 높이 차 ÷ 노선 연장이 상한을 넘으면 알림 — 종단으로 못 맞춤 · 노선을 늘려야 함", }, { name: "상한을 풂", diff --git a/resources/tester/helper_421_2_violation_lines.cjs b/resources/tester/helper_421_2_violation_lines.cjs new file mode 100644 index 000000000..0141d7cc7 --- /dev/null +++ b/resources/tester/helper_421_2_violation_lines.cjs @@ -0,0 +1,107 @@ +/* 421-2 추천 창 「못 지킨 구간」 · 원지반 기울기(참고) · 시종점 알림 헬퍼 — `_Initial_Model` 을 + * 그 자리에서 트랜스파일해 Node 로 돌려 줄 글을 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 nodes = [ + { x: 0, y: 0 }, + { x: 10, y: 5 }, +]; +const item = (kind, station, value, limit, reason) => ({ kind, station, value, limit, reason }); +const now = model.normalizeRouteInitial( + { + nodes, + curves: [], + metrics: { + max_grade_pct: 35.8, + over_limit: { grade: 2, radius: 1, station: 1, stream: 0, total: 4 }, + violations: [ + item("grade", "No.1", 28.8, 14, "원지반 기울기 — 참고"), + item("radius", "No.3", 10, 12, "지형상 불가능 — 곡선"), + item("grade", "No.4", 18.9, 14, "원지반 기울기"), + item("station", "No.5+3.0", 3, 0, "측점 밖"), + ], + }, + ends_grade_pct: 16.24, + ends_over_limit: true, + }, + "earthwork", + null, +); +const clean = model.normalizeRouteInitial( + { + nodes, + curves: [], + metrics: { + max_grade_pct: 12, + over_limit: { total: 1, grade: 1 }, + violations: [item("grade", "No.2", 15, 14, "")], + }, + ends_grade_pct: 3, + ends_over_limit: false, + }, + "follow", + null, +); +const old = model.normalizeRouteInitial( + { nodes, curves: [], metrics: { max_grade_pct: 9, over_limit: { total: 3 } } }, + "gentle", + null, +); + +const lines = (choice) => ({ + violations: model.violationsLine(choice.metrics), + ground: model.groundGradeLine(choice.metrics), + groundTitle: model.groundGradeTitle(choice.metrics), + ends: model.endsLine(choice), + over: choice.metrics.overLimitCount, + route: model.metricsLines(choice.metrics).route, +}); +process.stdout.write(JSON.stringify({ now: lines(now), clean: lines(clean), old: lines(old) })); diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index a2e195fbd..a829a8ddc 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -174,12 +174,13 @@ const labels = { violationsOld: model.violationsLine({ ...plain.metrics, violations: null }), violationsMany: model.violationsLine({ ...plain.metrics, + // 못 지킨 곳(R) 일곱 — 원지반 기울기는 참고 줄이라 이 줄에 안 듦(421-2) violations: Array.from({ length: 7 }, (_, i) => ({ - kind: "grade", + kind: "radius", station: `No.${i}+0.0`, - value: 15 + i, - limit: 14, - reason: "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음", + value: 5 + i, + limit: 12, + reason: "곡선 둘 자리 모자람 — 교점 사이가 짧음", })), }), relaxed: model.relaxedLine(plain), diff --git a/resources/tester/test_421_2_violation_lines.py b/resources/tester/test_421_2_violation_lines.py new file mode 100644 index 000000000..61ecd6769 --- /dev/null +++ b/resources/tester/test_421_2_violation_lines.py @@ -0,0 +1,61 @@ +"""421-2 추천 창 — 「못 지킨 구간」 = R · 계류 · 측점만 · 원지반 기울기(참고) 줄 · 시종점 알림. + +./venv/Scripts/python.exe -m pytest resources/tester/test_421_2_violation_lines.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_421_2_violation_lines.cjs") + + +def _lines() -> dict: + run = subprocess.run( + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True + ) + return json.loads(run.stdout) + + +def test_못_지킨_구간은_기울기_빼고_기준_초과와_같음() -> None: + now = _lines()["now"] + assert now["violations"].startswith("제약 못 지킨 구간 2곳: No.3 R 10.0m") + assert "28.8%" not in now["violations"] and "No.5+3.0 측점 밖 3.0m" in now["violations"] + assert now["over"] == 2 and "기준 초과 2구간" in now["route"] # 서버 total 4 아님 + assert now["ground"] == "원지반 기울기(참고) 최대 35.8% · 상한 넘는 곳 2" + assert now["groundTitle"].split("\n") == [ + "No.1 28.8%(원지반 기울기)", + "No.4 18.9%(원지반 기울기)", + ] + assert now["ends"] == ( + "시종점 높이 차가 상한을 넘음(평균 16.2%) — 종단으로 못 맞춤 · 노선을 늘려야 함" + ) + + +def test_기울기만_넘으면_모두_지킴_시종점_알림_없음() -> None: + clean = _lines()["clean"] + assert clean["violations"] == "제약 못 지킨 구간 없음 — 모두 지킴" + assert clean["over"] == 0 and clean["ground"].endswith("상한 넘는 곳 1") + assert clean["ends"] == "" + + +def test_옛_답은_서버_수_그대로_줄_없음() -> None: + old = _lines()["old"] + assert ( + old["over"] == 3 and old["violations"] == "" and old["ground"] == "" and old["ends"] == "" + ) + + +def test_창에_줄_두_개와_도움말() -> None: + rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8") + assert '' in rec + assert '' in rec + assert 'line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])' in rec + assert "const ends = endsLine(view);" in rec + help_ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend_Help.ts").read_text(encoding="utf-8") + for name in ('name: "원지반 기울기(참고)",', 'name: "시종점 높이 차",'): + assert name in help_ts + assert "이보다 가파른 구간을 만들지 않음" not in help_ts + assert "「기준 초과」 수와 같음" in help_ts diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index e85e406b0..0dfa3efff 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -94,14 +94,15 @@ def test_비교값과_기준값_글(results): def test_제약_못_지킨_구간과_상한_풂(results): labels = results["labels"] + # 원지반 기울기(No.9 29.1%)는 참고 줄로 — 못 지킨 구간에 안 듦(421-2) assert labels["violations"] == ( - "제약 못 지킨 구간 3곳: No.9+0.0 29.1%(지형상 불가능)" - " · No.20+0.0 R 9.5m(곡선 둘 자리 모자람) · No.30+0.0 계류 32.0m(계류 버퍼 안)" + "제약 못 지킨 구간 2곳: No.20+0.0 R 9.5m(곡선 둘 자리 모자람)" + " · No.30+0.0 계류 32.0m(계류 버퍼 안)" ) assert labels["violationsNone"] == "제약 못 지킨 구간 없음 — 모두 지킴" assert labels["violationsOld"] == "" # 옛 답(목록 칸 없음)은 줄을 숨긴다 assert labels["violationsMany"].startswith( - "제약 못 지킨 구간 7곳: No.0+0.0 15.0%(곡선 맞춤 뒤 지반 차)" + "제약 못 지킨 구간 7곳: No.0+0.0 R 5.0m(곡선 둘 자리 모자람)" ) assert labels["violationsMany"].endswith(" 외 2곳") assert labels["relaxed"] == "지형상 14.0% 로 못 이어져 상한을 23.5% 로 풂" @@ -382,5 +383,6 @@ def test_같은_노선_알림_줄(results): def test_비중_계산_넘은_구간_사유_그대로(results): six = results["sixth"] - assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)" + # 원지반 기울기만 넘음 = 못 지킨 곳 없음(421-2) · 사유 글은 참고 줄 풍선에 그대로 + assert six["weightedViolation"] == "제약 못 지킨 구간 없음 — 모두 지킴" assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)"