Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 · 없으면 키만(작은 글) · 둘 다 없으면 빈 키
|
||||
|
||||
Reference in New Issue
Block a user