Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-09-30 08:32:05 +09:00
26 changed files with 1306 additions and 271 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,
}),
);
+67 -13
View File
@@ -75,8 +75,26 @@ globalThis.fetch = async (url, init = {}) => {
const [, , folderEnc, partEnc] = m;
if (!folderEnc && method === "GET")
return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) });
if (folderEnc === "new" && method === "POST") {
if (state.noCompany)
return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." });
const item = {
layer: "personal",
owner: "1/3",
key: "SS900001",
name: [...body.경로, body.이름].join(" "),
};
return reply(200, {
key: "SS900001",
이름: item.name,
폴더: body.structure,
summary_item: item,
});
}
if (!folderEnc && method === "POST") {
const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`;
if (!body.structure)
return reply(400, { detail: "미리 가져올 구조물(structure)이 필요합니다." });
const folder = body.structure.replace("#", "n");
state.folders[folder] = {
info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" },
parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null },
@@ -86,9 +104,24 @@ globalThis.fetch = async (url, init = {}) => {
const item = state.folders[decodeURIComponent(folderEnc)];
if (!item) return reply(404, { detail: "복사본이 없음" });
const part = partEnc && decodeURIComponent(partEnc);
if (part === "refresh") {
if (part === "reapply") {
item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] };
return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" });
return reply(200, {
폴더: item.info.폴더,
가져온날: "2026-09-30T02:00:00",
원본판: "새판",
덮임: ["면적"],
});
}
if (part === "save-as" && method === "POST") {
if (state.noCompany)
return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." });
const name = body.이름 ?? "복사";
return reply(200, {
key: "SS900002",
이름: name,
summary_item: { layer: "personal", owner: "1/3", key: "SS900002", name },
});
}
if (part === "calc")
return reply(200, {
@@ -122,17 +155,24 @@ const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner
async function main() {
const out = {};
out.folders = [
project.folderOf(row()),
project.folderOf(row({ layer: "company", owner: "3" })),
project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })),
];
// 구조물 id → 복사본 폴더(서버 `folder_name`) · 관 지점 id(서버 `pipe@{round(측점, 3)}` 파이썬 float 글)
out.folders = ["st-1", "#3", "pipe@85.05"].map(project.structureFolder);
out.pipeIds = [85.05, 100, 258.12, 0.1234, 12.0004].map(project.pipeStructureId);
const items = project.projectItems("P1");
out.listEmpty = await items.list();
const info = await items.importItem(row({ layer: "personal", owner: "1/3" }));
const info = await items.importItem(row({ layer: "personal", owner: "1/3" }), "pipe@85.05");
out.info = info;
out.importCall = state.calls.find((c) => c.method === "POST");
out.sameItem = [
project.sameItem(info, row({ layer: "personal", owner: "1/3" })),
project.sameItem(info, row({ layer: "personal", owner: "1/3", key: "SS000002" })),
project.sameItem(info, row()),
];
await items.importItem(row(), "").then(
() => (out.noStructure = "성공"),
(error) => (out.noStructure = error.message),
);
out.list = (await items.list()).map((x) => x.폴더);
out.read = await items.read(info.폴더, "면적");
@@ -164,11 +204,25 @@ async function main() {
);
state.readOnly = false;
const fresh = await items.refresh(info);
out.refresh = fresh;
out.refreshCall = state.calls
.filter((c) => /refresh$/.test(c.url))
const again = await items.reapply(info);
out.reapply = again;
out.reapplyCall = state.calls
.filter((c) => /reapply$/.test(c.url))
.map((c) => `${c.method} ${c.url}`);
out.saveAs = await items.saveAs(info.폴더, ["sub5", "경로"], "내 항목");
out.saveAsCall = state.calls.find((c) => /save-as$/.test(c.url)).body;
out.newItem = await items.newItem("pipe@85.05", ["개인", "폴더"], "새 항목", "m");
out.newCall = state.calls.find((c) => /items\/new$/.test(c.url)).body;
state.noCompany = true;
await items.saveAs(info.폴더, [], "x").then(
() => (out.noCompanySave = "성공"),
(error) => (out.noCompanySave = error.message),
);
await items.newItem("pipe@85.05", [], "x", "").then(
() => (out.noCompanyNew = "성공"),
(error) => (out.noCompanyNew = error.message),
);
state.noCompany = false;
out.calc = await items.calc(info.폴더);
out.calcMissing = await items.calc("없는폴더");
@@ -76,4 +76,6 @@ def test_M02_만_늦게_불러옴():
users.append(rel)
# 서버 Node 진입만 정적(브라우저 번들 밖)
assert offenders == ["common_util/common_util_spreadsheet_node.ts"], offenders
assert users and all(u.startswith("M02_MasterTemplete/") for u in users), users
# M02 화면 + 고르기 모달 편집 화면(카드 [수정] 을 누를 때만 `import(`)
later = ("M02_MasterTemplete/", "ui_template/ui_template_item_picker_edit.ts")
assert users and all(u.startswith(later) for u in users), users
@@ -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}),
]
@@ -128,6 +128,7 @@ def test_지정하면_새_파일까지_복사(world):
"원본없음": False,
"구조물": ["pipe@40.0"],
"고침": False,
"값있음": False,
}
]
@@ -522,3 +523,19 @@ def test_새_항목_길_회사_없으면_409(world, monkeypatch):
assert client.post("/api/projects/p1/items/new", json=body).status_code == 409
save_as = client.post("/api/projects/p1/items/f1/save-as", json={"경로": ["a"], "이름": "b"})
assert save_as.status_code == 409
def test_재적용_뒤_고침은_꺼지고_값있음만_남음(world):
root = world["root"]
_save(root, [_pipe()])
_memo(root, FOLDER, "x")
assert copies.list_items(root)[0]["고침"] is True
got = copies.reapply(root, FOLDER)
assert got["값있음"] is False
row = copies.list_items(root)[0]
assert row["고침"] is False and row["값있음"] is False
_fill_values(root) # 입력값 씀 — 고침 켜짐
assert copies.list_items(root)[0]["고침"] is True
assert copies.reapply(root, FOLDER)["값있음"] is True
row = copies.list_items(root)[0]
assert row["고침"] is False and row["값있음"] is True
+52 -16
View File
@@ -36,26 +36,28 @@ def out():
return json.loads(proc.stdout)
def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out):
rows = [
{"layer": "system", "owner": None, "key": "SS000001"},
{"layer": "company", "owner": "3", "key": "SS000001"},
{"layer": "personal", "owner": "3/12", "key": "SS000002"},
def test_구조물_id_는_복사본_폴더와_관_지점_id_로(out):
assert out["folders"] == ["st-1", "n3", "pipe@85.05"] # `#차례` = `n차례` (서버 folder_name)
# 서버 `pipe@{round(측점, 3)}` — 파이썬 float 글(정수도 `100.0`)
assert out["pipeIds"] == [
f"pipe@{round(x, 3)}" for x in (85.05, 100.0, 258.12, 0.1234, 12.0004)
]
assert out["folders"] == [snapshot_folder_name(row) for row in rows]
assert out["info"]["폴더"] == snapshot_folder_name(
{"layer": "personal", "owner": "1/3", "key": "SS000001"}
)
def test_가져오기는_POST_원본_참조_목록은_항목(out):
def test_가져오기는_구조물마다_POST_본문에_structure(out):
assert out["listEmpty"] == []
assert out["importCall"] == {
"method": "POST",
"url": "/api/projects/P1/items",
"body": {"layer": "personal", "owner": "1/3", "key": "SS000001"},
"body": {"structure": "pipe@85.05", "layer": "personal", "owner": "1/3", "key": "SS000001"},
}
assert out["list"] == [out["info"]["폴더"]]
assert out["info"]["폴더"] == "pipe@85.05" # 폴더 = 구조물 id
assert out["list"] == ["pipe@85.05"]
assert out["noStructure"] == "미리 가져올 구조물(structure)이 필요합니다." # 서버 까닭 그대로
def test_같은_구조물이_다른_항목으로_바뀌었는지_원본_참조로_가림(out):
assert out["sameItem"] == [True, False, False]
def test_읽기는_내용만_돌려주고_없으면_null(out):
@@ -76,14 +78,31 @@ def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로
assert out["readOnly"] == "프로젝트 편집 권한이 없음"
def test_새로_가져오기와_계산_한_줄(out):
assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/")
assert out["refreshCall"][0].endswith("/refresh")
assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판"
def test_재적용은_덮임을_돌려주고_계산_한_줄(out):
assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply"
assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로
assert out["reapply"]["info"]["마스터바뀜"] is False
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1}
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
def test_개인에_저장과_새_항목_본문_summary_item(out):
assert out["saveAsCall"] == {"to": "personal", "경로": ["sub5", "경로"], "이름": "내 항목"}
assert out["saveAs"]["summary_item"]["key"] == "SS900002"
assert out["newCall"] == {
"structure": "pipe@85.05",
"경로": ["개인", "폴더"],
"이름": "새 항목",
"단위": "m",
}
assert out["newItem"]["summary_item"]["name"] == "개인 폴더 새 항목"
def test_회사_없음_409_는_서버_까닭_그대로(out):
for key in ("noCompanySave", "noCompanyNew"):
assert out[key].startswith("회사부터 등록"), key
def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out):
assert out["inputsHits"] == 1
assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
@@ -164,3 +183,20 @@ def test_소스가_700줄을_넘지_않음():
M02 / "M02_MasterTemplete_Inputs_Model.ts",
):
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리():
tree = (UI / "ui_template_tree.ts").read_text(encoding="utf-8")
assert "menu?: () => TreeMenuItem[]" in tree and "export function openTreeMenu(" in tree
assert '"contextmenu"' in tree
picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8")
# 새 항목 메뉴는 개인 그룹 · 폴더에만(마스터 · 회사는 메뉴 없음) · 프로젝트 안에서만
assert 'folder.layer !== "personal" || !opts.projectId' in picker
assert "menu: menuOf(node)" in picker
def test_B05_구조물_폼이_구조물_id_를_모달에_넘김():
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(
encoding="utf-8"
)
assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel
+63 -26
View File
@@ -29,6 +29,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, stat
from common_util.common_util_route_polyline import build_planned_polyline
SEARCHED = [mode for mode in MODES if mode != "follow"]
# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a)
BRANCHES = ["earthwork", "gentle", "short", "balance"]
GRADE = 0.14
RADIUS = 12.0
@@ -76,7 +78,7 @@ def hill() -> Terrain:
)
@pytest.mark.parametrize("mode", SEARCHED)
@pytest.mark.parametrize("mode", BRANCHES)
def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
result = _run(hill, mode)
_kept(result)
@@ -105,12 +107,24 @@ def _earth(result: dict) -> float:
return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"]
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
"""토공 최소 = 절성토 가장 적음 · 기울기 순한 = 최대 기울기 가장 낮음(같은 후보 묶음)."""
runs = {mode: _run(hill, mode) for mode in SEARCHED}
assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in runs.values())
def _check_branch_goals(runs: dict) -> None:
"""토공 최소 = 절성토 · 기울기 순한 = 최대 기울기 · 짧은 노선 = 연장 · 흙 균형 = |절토 − 성토|
가 갈래(같은 후보 묶음) 가운데 가장 좋음 · 흙 남음 = 절토 − 성토."""
branches = [runs[mode] for mode in BRANCHES]
assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in branches)
top = runs["gentle"]["metrics"]["max_grade_pct"]
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values())
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in branches)
shortest = runs["short"]["metrics"]["length_m"]
assert all(shortest <= r["metrics"]["length_m"] + 1e-6 for r in branches)
surplus = abs(runs["balance"]["metrics"]["surplus_m3"])
assert all(surplus <= abs(r["metrics"]["surplus_m3"]) + 0.1 for r in branches)
for result in runs.values():
metrics = result["metrics"]
assert abs(metrics["surplus_m3"] - (metrics["cut_m3"] - metrics["fill_m3"])) <= 0.11
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
_check_branch_goals({mode: _run(hill, mode) for mode in MODES})
# 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선
@@ -144,7 +158,8 @@ def _check_weight_ends(run) -> None:
def _check_weight_monotone(run) -> None:
"""비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질)."""
"""예상 몫을 줄이면 예상노선 이격이 늘기만 함(비중 탐색 = 가중합 최소의 성질) · 토공 ↔ 기울기
끝값(갈래)은 그 사이 어느 비중보다 제 목표에서 좋음(쓸기가 후보 묶음에 들어 있음)."""
def metrics(steps):
keys = ("expected", "earthwork", "grade")
@@ -155,8 +170,8 @@ def _check_weight_monotone(run) -> None:
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
assert cuts[-1] <= min(cuts) + 1e-6, cuts # 끝 0/100/0 = 토공 최소
assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한
def test_weight_ends_match_branches(hill: Terrain) -> None:
@@ -185,7 +200,7 @@ def test_stream_offset_is_hard() -> None:
"""평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감."""
flat = _terrain(lambda xx, yy: np.zeros_like(xx))
stream = [[(0.0, 220.0), (800.0, 220.0)]]
for mode in SEARCHED:
for mode in BRANCHES:
result = _run(flat, mode, streams=stream)
_kept(result)
assert result["metrics"]["over_limit"]["stream"] == 0
@@ -203,7 +218,7 @@ def test_stream_crossing_is_allowed() -> None:
def test_terrain_impossible_is_listed() -> None:
"""복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기."""
wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0))
result = _run(wall, "weighted")
result = _run(wall, "earthwork")
_kept(result)
assert result["relaxed"] is True
assert result["terrain_bound_pct"] > GRADE * 100.0
@@ -222,7 +237,8 @@ def test_grade_line_stays_within_limit() -> None:
def test_weights_and_stream_filter() -> None:
assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"}
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted", "short", "balance"}
assert resolve_weights("short", {"expected": 99})["expected"] == 0.0
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
features = [
{"properties": {"구분": "세류"}, "geometry": line},
@@ -302,6 +318,8 @@ def test_request_accepts_logic_name() -> None:
assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle"
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
assert RouteInitialRequest.model_validate({"logic": "short"}).mode == "short"
assert RouteInitialRequest.model_validate({"logic": "balance"}).mode == "balance"
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
assert replan.initial is not None and replan.initial.mode == "weighted"
@@ -385,20 +403,39 @@ 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} # 비중은 50/25/25
for first in SEARCHED:
for second in SEARCHED:
if first < second:
gap = LineString(searched[first]["planned"]).hausdorff_distance(
LineString(searched[second]["planned"])
)
assert gap >= 3.0, (first, second, gap)
assert all(_earth(searched["earthwork"]) <= _earth(r) for r in searched.values())
top = searched["gentle"]["metrics"]["max_grade_pct"]
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"])
"""각 갈래가 제 목표 비교값에서 가장 좋음 · 토공 최소와 기울기 순한은 선이 갈림(6.1 m)."""
_check_branch_goals({mode: project_runs[mode] for mode in MODES})
assert _gap(project_runs["earthwork"], project_runs["gentle"]) >= 3.0
for mode in MODES:
metrics = project_runs[mode]["metrics"]
assert metrics["over_limit"]["total"] == len(metrics["violations"])
def test_project_similar_and_band(project_runs: dict) -> None:
"""가파른 지형 — 제약 안 길이 한 띠(띠 폭 < 20 m) · 10 m 안인 다른 갈래를 `similar` 로 알림."""
earthwork = project_runs["earthwork"]
assert earthwork["band_width_m"] < 20.0
near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]}
assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1)
assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near
assert "follow" not in near # 추적은 40 m 넘게 떨어짐
def test_project_expected_axis_moves_little_by_little(project_runs: dict) -> None:
"""예상 100 → 0(토공 : 기울기 1 : 1) — 99 는 추적에서 몇 m 안 · 이웃 단계마다 노선이 움직임 ·
추적에서 점점 멀어짐(이격 늘기만) · 비중 탐색은 넘은 곳을 「비중 계산」 사유로 알림."""
run = project_runs["run"]
near = run("weighted", {"expected": 99, "earthwork": 0.5, "grade": 0.5})
assert 0.0 < _gap(near, project_runs["follow"]) < 10.0
steps = [
run("weighted", {"expected": e, "earthwork": (100 - e) / 2, "grade": (100 - e) / 2})
for e in range(100, -1, -10)
]
assert all(_gap(a, b) > 0.0 for a, b in zip(steps, steps[1:]))
offsets = [step["metrics"]["offset_avg_m"] for step in steps]
assert offsets == sorted(offsets), offsets
reasons = [v["reason"] for v in steps[5]["metrics"]["violations"] if v["kind"] == "grade"]
assert reasons and all(reason.startswith("비중 계산") for reason in reasons)
def test_project_weight_ends_match_branches(project_runs: dict) -> None:
+31 -10
View File
@@ -10,6 +10,7 @@ import {
isCurrent,
itemName,
pathIds,
pathOf,
rowId,
rowTip,
shown,
@@ -89,13 +90,8 @@ assert.equal(
"옹벽 H=2m",
);
const placeholderTree = buildTree([fresh], labels);
assert.equal(placeholderTree[2].children?.[0].label, "분류 없음"); // 종류
assert.equal(placeholderTree[2].children?.[0].children?.[0].label, "분류 없음"); // 분류1
assert.deepEqual(pathIds(fresh, labels), [
"personal",
"personal|분류 없음",
"personal|분류 없음|분류 없음",
]);
assert.equal(placeholderTree[2].children?.[0].label, "n1"); // 경로가 비면 폴더 없이 그룹 바로 밑 잎
assert.deepEqual(pathIds(fresh, labels), ["personal"]);
// 툴팁 = 층 이름 + 키
assert.equal(rowTip(rows[7], labels), "개인 · SS000201");
@@ -123,14 +119,38 @@ assert.deepEqual(
const cat = system.children?.[0].children?.[0];
assert.equal(cat?.label, "메쌓기");
assert.equal(cat?.count, 2);
const leaf = cat?.children?.[0];
const size = cat?.children?.[0]; // 폴더 깊이 자유 — 경로 앞부분이 모두 폴더
assert.equal(size?.label, "1.2m");
assert.deepEqual(size?.folder, { layer: "system", path: ["돌기슭막이", "메쌓기", "1.2m"] });
assert.deepEqual(tree[2].folder, { layer: "personal", path: [] }); // 그룹 줄 = 빈 경로
const leaf = size?.children?.[0];
assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(leaf?.tip, "마스터 · SS000001"); // 툴팁 = 층 이름 + 키
assert.equal(leaf?.count, null);
assert.ok(leaf?.row);
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
assert.equal(tree[1].children?.[1].children?.[0].label, "분류 없음");
// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「옹벽 H=2m」)
assert.equal(tree[1].children?.[1].label, "옹벽");
assert.equal(tree[1].children?.[1].children?.[0].label, "옹벽 H=2m");
assert.equal(tree[1].children?.[1].children?.[0].folder, undefined); // 잎에는 folder 없음
// 서버가 준 `경로` 가 종류 · 분류보다 앞 — 이름 = 경로 이음 · 자리표는 뺌
const deep = row("SS000400", "d1", "무시", ["무시", "", ""], "personal", {
경로: ["가", "나", "다", "라", "마"],
});
assert.deepEqual(pathOf(deep), ["가", "나", "다", "라", "마"]);
assert.equal(itemName(deep), "가 나 다 라 마");
assert.deepEqual(pathIds(deep), [
"personal",
"personal|가",
"personal|가|나",
"personal|가|나|다",
"personal|가|나|다|라",
]);
assert.deepEqual(pathOf({ key: "k", 열: "x", 경로: ["새 분류1", "옹벽"] }), ["옹벽"]);
const deepTree = buildTree([deep], labels)[2];
assert.equal(deepTree.children?.[0].children?.[0].children?.[0].children?.[0].label, "라");
assert.equal(deepTree.count, 1);
// 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹)
const onlySystem = buildTree(
@@ -198,6 +218,7 @@ assert.deepEqual(pathIds(rows[0], labels), [
"system",
"system|돌기슭막이",
"system|돌기슭막이|메쌓기",
"system|돌기슭막이|메쌓기|1.2m",
]);
// 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키