Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-09-29 20:28:28 +09:00
32 changed files with 1938 additions and 395 deletions
@@ -0,0 +1,31 @@
/* 배수유역 관 보고(`B05_Profile_UI_Drainage_Report.ts`) 셈 단위검증 헬퍼 — TS 를 트랜스파일해 Node 로 돈다. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Report.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const { buildPipeReports } = box.exports;
const store = {
get: (chainage) =>
chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null,
};
const basins = [
{ chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false },
{ chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true },
];
const out = buildPipeReports(
[
{ chainage_m: 40.004, reason: "auto" },
{ chainage_m: 100.499 },
{ chainage_m: 200 },
],
basins,
store,
);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,180 @@
/* 설계 가게 · 저장 한 줄기(계획서 24-2a) 단위검증 헬퍼 —
* `A00_Common/design/design_store.ts` · `design_save.ts` · `B05_Profile_UI_Profile_Edit.ts` 를
* 그 자리에서 트랜스파일해 Node 로 돌린다. 서버 창구는 가짜로 두고 부른 차례만 적는다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
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;
},
};
}
const session = memoryStorage();
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
globalThis.sessionStorage = session;
const calls = [];
const record = (name, fail) => async () => {
calls.push(name);
if (fail) throw new Error(`${name} 실패`);
};
let failStructures = false;
const STUBS = {
"@ui/ui_template_elements": {
showToast: (text, kind) => calls.push(`toast:${kind}`),
showLoadingOverlay: () => calls.push("overlay:on"),
hideLoadingOverlay: () => calls.push("overlay:off"),
},
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
flushPendingPipes: () => record("pipes")(),
},
"../../B05_Profile/B05_Profile_Api_Structures": {
flushPendingStructures: () => record("structures", failStructures)(),
},
"../../B05_Profile/B05_Profile_Api_Fetch": {
flushUphillOverrides: () => record("uphill")(),
saveProfileAlignment: async (_p, routeId, edits) =>
calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`),
},
"../../B06_Section/B06_Section_Api_Culvert_Options": {
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
},
};
const cache = new Map();
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.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 { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
const { saveDesign, DESIGN_SAVE_ORDER } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
);
async function run() {
const out = {};
// ① 가게 — 등록 키 밑 · 같은 칸이면 두 가게가 같은 값 · 듣는 쪽
const a = useDesignStore("p1", 7);
const b = useDesignStore("p1", 99); // 프로젝트 칸은 노선과 무관
const heard = [];
const off = b.subscribe("pipes", (value) => heard.push(value.length));
a.set("pipes", [{ chainage_m: 10, source: "user" }]);
out.pipesShared = b.get("pipes");
out.pipesKey = [...Array(session.length).keys()].map((i) => session.key(i));
off();
a.set("pipes", [{ chainage_m: 10 }, { chainage_m: 20 }]);
out.heard = heard;
out.otherProject = useDesignStore("p2", 7).get("pipes");
out.noRoute = useDesignStore("p1", null).get("alignmentDraft");
a.set("selection", { chainageM: 12.5, structureId: "s-1" });
out.selection = b.get("selection");
a.set("pipes", null);
a.set("selection", null);
// ② 계획고 가게 한 벌 — B05 패널 · B06 계획선 편집이 같은 벌
const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } };
const b05 = edit.createProfileEditStore(7, saved, () => undefined);
const b06 = edit.useProfileEditStore(7, saved, () => undefined);
b06.replace({ station_offsets: { "20.000": 0.5 }, curve_radii: saved.curve_radii });
out.b05SeesB06 = b05.edits().station_offsets;
out.b05Dirty = b05.dirty();
// B05 가 자기 손에 쥔 값으로 이어 고쳐도 B06 편집이 안 사라짐(옛 두 가게 흠)
b05.replace({
station_offsets: { ...b05.edits().station_offsets, "60.000": -0.2 },
curve_radii: b05.edits().curve_radii,
});
out.afterBoth = b06.edits().station_offsets;
out.draft = useDesignStore(null, 7).get("alignmentDraft").station_offsets;
out.pending = edit.pendingAlignment(7).station_offsets;
// 되돌리기가 초안을 바꾼 뒤 B05 가 다시 세움 — B06 쪽 손잡이도 같은 벌을 봄
useDesignStore(null, 7).set("alignmentDraft", {
station_offsets: { "20.000": 0.1 },
curve_radii: {},
});
edit.createProfileEditStore(7, saved, () => undefined);
out.rebuiltSeenByB06 = b06.edits().station_offsets;
// ③ 저장 한 줄기 — 차례 · 기본 창구 · 보기 몫 · 계획고 저장됨 표시
calls.length = 0;
const ok = await saveDesign({
projectId: "p1",
routeId: () => 7,
prepare: record("prepare"),
steps: { pipes: record("pipes:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
done: async (confirm) => calls.push(`done:${confirm}`),
failed: () => calls.push("failed"),
});
out.saveOk = ok;
out.saveCalls = [...calls];
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
out.order = DESIGN_SAVE_ORDER;
// ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감
calls.length = 0;
failStructures = true;
await saveDesign(
{
projectId: "p1",
routeId: () => 7,
steps: { uphill: record("uphill:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
failed: () => calls.push("failed"),
},
{ confirm: true },
);
failStructures = false;
out.tolerant = [...calls];
calls.length = 0;
out.noRouteSave = await saveDesign({
projectId: "p1",
routeId: () => null,
cross: async () => calls.push("cross"),
failed: () => calls.push("failed"),
});
out.noRouteCalls = [...calls];
// ⑤ [초기화] — 초안 · 한 벌 모두 버림
b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} });
edit.clearAlignmentDrafts();
out.cleared = {
draft: useDesignStore(null, 7).get("alignmentDraft"),
pending: edit.pendingAlignment(7),
};
return out;
}
run().then(
(out) => console.log(JSON.stringify(out)),
(error) => {
console.error(error);
process.exit(1);
},
);
@@ -474,23 +474,25 @@ def _resave_unchanged(flow: Flow) -> None:
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 —
# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a).
def _b05_save(flow: Flow) -> None:
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
"""B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정."""
flow.put_pipes(flow.b05_pipe_payload())
flow.put_alignment()
flow.put_structures(flow.screen_structures(reread=True))
flow.confirm(with_irregular=True)
flow.put_alignment()
flow.flush_culvert()
flow.confirm(with_irregular=True)
flow.save_sections()
def _b06_save(flow: Flow) -> None:
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
"""B06 [저장] — 오르막 차례는 상단측만 확정."""
flow.put_pipes(flow.b06_pipe_payload())
flow.put_structures(flow.screen_structures(reread=False))
flow.put_alignment()
flow.flush_culvert()
flow.confirm(with_irregular=False)
flow.put_alignment()
flow.put_structures(flow.screen_structures(reread=False))
flow.save_sections()
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""배수유역도 판이 종단 패널을 모르고 홀로 선다(계획서 24-2e).
- 배수유역 파일(`B05_Profile_UI_Drainage_*`)은 종단 패널(`_Profile_*`)이나 B06 을 가져오지 않는다 —
종단쪽 일은 `_Panel_Types` 약속(콜백 · 창구)으로만 오간다.
- 파일마다 700줄 이하.
- 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null ·
시설 정보 · 출처 기본값 user.
- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판).
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HERE = os.path.dirname(os.path.abspath(__file__))
def _drainage_files() -> list[Path]:
return sorted(B05.glob("B05_Profile_UI_Drainage_*.ts"))
def test_배수유역_파일은_종단_패널과_B06을_안_가져옴():
files = _drainage_files()
assert len(files) >= 10
for path in files:
source = path.read_text(encoding="utf-8")
for line in source.splitlines():
if not line.lstrip().startswith(("import ", "} from ", "export {", "export type {")):
continue
assert "UI_Profile_" not in line, (path.name, line)
assert "B06_Section" not in line, (path.name, line)
def test_배수유역_파일은_700줄_이하():
for path in _drainage_files():
lines = len(path.read_text(encoding="utf-8").splitlines())
assert lines <= 700, (path.name, lines)
def test_켜고_끄는_창구():
side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8")
for name in (
"createDrainageSide",
"createDrainageToggleButton",
"setVisible",
"onVisibleChange",
):
assert name in side
assert "root.hidden" in side or "panel.root.hidden" in side
# 종단도 판 자리 약속(`DesignSideFactory` · design_profile.ts) 모양 그대로
assert "DesignSideBoard" in side and "DesignSideHost" in side
assert "export function createDrainageSide(" in side
types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8")
assert "export interface DrainagePipeReport" in types
assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types
@pytest.fixture(scope="module")
def reports():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_drainage_report.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_관_보고_유역_매칭과_기본값(reports):
first, box, plain = reports
# 누가거리는 소수 둘째 자리로 · 담당 유역의 관경 · 설계유량이 붙는다.
assert first["chainage_m"] == 40 and first["effective_diameter_mm"] == 800
assert first["design_flow_m3s"] == 1.2 and first["facility"] == "pipe"
assert first["source"] == "auto"
# 세월교 검토 유역은 관경만 null · 설계유량은 남김 · 시설 정보를 데려옴.
assert box["chainage_m"] == 100.5 and box["effective_diameter_mm"] is None
assert box["design_flow_m3s"] == 3.4
assert box["facility"] == "box_culvert" and (box["start_m"], box["end_m"]) == (95, 106)
assert box["options"] == {"h": 2} and box["source"] == "user"
# 유역 없는 관 — 모두 null · 맨 배관.
assert plain["effective_diameter_mm"] is None and plain["design_flow_m3s"] is None
assert plain["facility"] == "pipe"
@@ -192,3 +192,24 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
{"logic": "earthwork"},
None,
]
def test_노선_추천_다듬기_23_3():
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
page = os.path.join(HERE, "..", "..", "B05_Profile")
def read(name):
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
rec = chrome.index('data-act="recommend"')
divider = chrome.index("b05-routeedit__divider", rec)
assert rec < divider < chrome.index('data-act="contour-minor"')
assert chrome.count("b05-routeedit__divider") == 2
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
assert f"{name}(" in shared
css = read("B05_Profile_UI_Style_RouteRecommend.css")
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
assert "writing-mode: vertical-lr" in css
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
+28 -6
View File
@@ -63,14 +63,36 @@ def test_창구_넷은_초안이_비면_요청을_안_낸다():
def test_저장이_창구를_다섯_다_거친다():
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다."""
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다.
차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) —
B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다."""
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
for token in (
"flushCulvertOptions()",
"flushCulvertOptions(",
"flushUphillOverrides(",
"flushPendingPipes(",
"flushPendingStructures(",
):
assert token in persist, f"{token} 가 저장 앞단에서 사라짐"
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다.
assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐"
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다.
assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
order = order[: order.index("]")]
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), (
"저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단"
)
def test_두_페이지가_저장_한_줄기를_탄다():
"""B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a)."""
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8")
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
for name, text in (("B05", actions), ("B06", persist)):
assert "saveDesign(" in text, f"{name} 가 저장 한 줄기를 안 탐"
for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("):
assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림"
assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐"
+13 -4
View File
@@ -8,6 +8,8 @@
⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서**
`ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다.
(계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의
`prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.)
"""
from __future__ import annotations
@@ -17,6 +19,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PERSIST = ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts"
PAGE = ROOT / "B06_Section" / "B06_Section_UI_Page.ts"
SAVE = ROOT / "A00_Common" / "design" / "design_save.ts"
def _body(source: str, marker: str) -> str:
@@ -30,11 +33,17 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
"export async function saveCurrentSections",
"export async function confirmCurrentSections",
):
body = _body(text, marker)
assert "ctx.reconcileDesigns()" in body, f"{marker} 가 재계산을 안 부른다"
assert body.index("ctx.reconcileDesigns()") < body.index("collectSectionEdits(ctx)"), (
f"{marker}: 재계산이 면적 수집보다 뒤에 있다 — 옛 면적이 실린다"
assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], (
f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다"
)
body = _body(text, "function sectionSaveContext")
assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다"
assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖"
save = SAVE.read_text(encoding="utf-8")
run = _body(save, "export async function saveDesign")
assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), (
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
)
def test_페이지가_재계산_통로를_이어_준다() -> None:
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""설계 가게 한 곳 · 저장 한 줄기(계획서 24-2a) 회귀검증.
- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽.
- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠).
- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 ·
계획고는 실패해도 다음 차례로.
Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_design_store_save.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_가게는_등록_키_밑_같은_칸은_같은_값(out):
assert out["pipesShared"] == [{"chainage_m": 10, "source": "user"}]
assert out["pipesKey"] == ["aislo:draft:pipes:p1"]
assert out["heard"] == [1], "끊은 뒤에는 안 불려야 함"
assert out["otherProject"] is None
assert out["noRoute"] is None
assert out["selection"] == {"chainageM": 12.5, "structureId": "s-1"}
def test_계획고_가게_한_벌(out):
assert out["b05SeesB06"] == {"20.000": 0.5}
assert out["b05Dirty"] is True
assert out["afterBoth"] == {"20.000": 0.5, "60.000": -0.2}
assert out["draft"] == out["afterBoth"] == out["pending"]
assert out["rebuiltSeenByB06"] == {"20.000": 0.1}
def test_저장_차례(out):
assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
assert out["saveOk"] is True
assert out["saveCalls"] == [
"overlay:on",
"prepare",
"pipes",
"pipes:screen",
"structures",
'alignment:7:{"20.000":0.1}',
"culvert:aislo:draft:culvertopt:p1:7",
"uphill",
"cross:false",
"done:false",
"overlay:off",
]
assert out["afterSave"] == {"dirty": False, "draft": None}
def test_구조물_실패는_알리고_넘어감_확정_노선없음(out):
calls = out["tolerant"]
assert "toast:error" in calls
assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen")
assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음"
assert calls[-2:] == ["cross:true", "overlay:off"]
assert "failed" not in calls
assert out["noRouteSave"] is False and out["noRouteCalls"] == []
def test_초기화는_초안과_한_벌을_버림(out):
assert out["cleared"] == {"draft": None, "pending": None}
+71 -6
View File
@@ -113,6 +113,60 @@ def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values())
# 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선
ENDS = {
"follow": {"expected": 100, "earthwork": 0, "grade": 0},
"earthwork": {"expected": 0, "earthwork": 100, "grade": 0},
"gentle": {"expected": 0, "earthwork": 0, "grade": 100},
}
# 예상 100 → 0(나머지 반반) · 기울기 100 → 토공 100
TOWARD_OTHERS = [
(100, 0, 0),
(90, 5, 5),
(75, 12.5, 12.5),
(50, 25, 25),
(25, 37.5, 37.5),
(0, 50, 50),
]
TOWARD_EARTH = [(0, 0, 100), (0, 25, 75), (0, 50, 50), (0, 75, 25), (0, 100, 0)]
def _gap(first: dict, second: dict) -> float:
return LineString(first["planned"]).hausdorff_distance(LineString(second["planned"]))
def _check_weight_ends(run) -> None:
for mode, weights in ENDS.items():
end, own = run("weighted", weights), run(mode)
assert _gap(end, own) < 0.01, (mode, _gap(end, own))
assert end["nodes"] == own["nodes"]
assert end["metrics"]["length_m"] == own["metrics"]["length_m"]
def _check_weight_monotone(run) -> None:
"""비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질)."""
def metrics(steps):
keys = ("expected", "earthwork", "grade")
return [run("weighted", dict(zip(keys, step)))["metrics"] for step in steps]
offsets = [m["offset_avg_m"] for m in metrics(TOWARD_OTHERS)]
assert offsets == sorted(offsets), offsets
earth = metrics(TOWARD_EARTH)
cuts = [m["cut_m3"] + m["fill_m3"] for m in earth]
tops = [m["max_grade_pct"] for m in earth]
assert cuts == sorted(cuts, reverse=True), cuts
assert tops == sorted(tops), tops
def test_weight_ends_match_branches(hill: Terrain) -> None:
_check_weight_ends(lambda mode, weights=None: _run(hill, mode, weights=weights))
def test_weight_moves_monotone(hill: Terrain) -> None:
_check_weight_monotone(lambda mode, weights=None: _run(hill, mode, weights=weights))
def test_follow_stays_on_expected_where_allowed() -> None:
"""고른 경사(2 %) — 추종은 예상노선을 그대로(격자 칸 반 안쪽)."""
ramp = _terrain(lambda xx, yy: 0.02 * xx)
@@ -301,18 +355,19 @@ def project_runs() -> dict:
streams = stream_lines(_reproject_features(features, to_metric))
route = read_planned_route_csv(expected_route_csv_path(PROJECT))
expected = [(float(v.x), float(v.y)) for v in route.vertices]
return {
mode: generate_initial_route(
def run(mode: str, weights: dict | None = None) -> dict:
return generate_initial_route(
expected,
terrain,
streams,
mode=mode,
weights=None,
weights=weights,
max_grade=GRADE,
min_radius_m=RADIUS,
)
for mode in MODES
}
return {**{mode: run(mode) for mode in MODES}, "run": run}
def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
@@ -331,7 +386,7 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
def test_project_branches_differ_by_goal(project_runs: dict) -> None:
"""갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음."""
searched = {mode: project_runs[mode] for mode in SEARCHED}
searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25
for first in SEARCHED:
for second in SEARCHED:
if first < second:
@@ -344,3 +399,13 @@ def test_project_branches_differ_by_goal(project_runs: dict) -> None:
assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values())
for result in searched.values():
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
def test_project_weight_ends_match_branches(project_runs: dict) -> None:
"""랩탑_보조 — 비중 100/0/0 = 추적(1078.01 m) · 0/100/0 = 토공 최소 · 0/0/100 = 기울기 순한."""
_check_weight_ends(project_runs["run"])
assert project_runs["run"]("weighted", ENDS["follow"])["metrics"]["length_m"] == 1078.01
def test_project_weight_moves_monotone(project_runs: dict) -> None:
_check_weight_monotone(project_runs["run"])