Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-16 12:18:15 +09:00
13 changed files with 687 additions and 123 deletions
@@ -33,6 +33,10 @@ const STUBS = {
showLoadingOverlay: () => calls.push("overlay:on"),
hideLoadingOverlay: () => calls.push("overlay:off"),
},
// 노선 저장(346-3) — 이 시험은 노선이 그대로인 [저장]만 봄
[path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts")]: {
pendingRouteSave: async () => null,
},
[path.join(B06, "B06_Section_Api_Fetch.ts")]: {
fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()),
saveSections: async () => calls.push("cross:save"),
@@ -0,0 +1,171 @@
/* 346-3 노선 저장 이음 헬퍼 — `B05_Profile_UI_RouteEdit_Apply.ts` 를 트랜스파일해 서버 · 화면 창구는 가짜로,
* 가게 · 캐시(`design_store` · `b_page_state`)는 진짜로 돌림. 보낸 본문 · 부른 차례를 적어 JSON 한 벌로 냄.
*/
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 bodies = [];
let failReplan = false;
const STUBS = {
"@ui/ui_template_elements": {
showToast: (text, kind) => calls.push(`toast:${kind}:${text}`),
showLoadingOverlay: () => calls.push("overlay:on"),
hideLoadingOverlay: () => calls.push("overlay:off"),
},
"../A00_Common/router": {
currentRoute: () => "b05-profile",
remountRoute: () => calls.push("remount"),
},
"../B06_Section/B06_Section_Section_Store": {
invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`),
loadSectionDetail: async (_p, routeId) => (calls.push(`load:${routeId}`), { tag: routeId }),
saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1),
},
"../B06_Section/B06_Section_Station_Keys": {
useStationKeys: (_p, routeId, detail) => calls.push(`keys:${routeId}:${detail.tag}`),
carryStationDrafts: (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2),
},
"./B05_Profile_Api_Fetch": { clearRouteLatestCache: () => calls.push("latest:clear") },
"./B05_Profile_Api_Replan": {
replanRoute: async (_p, body) => {
bodies.push(JSON.parse(JSON.stringify(body)));
calls.push("replan");
if (failReplan) throw new Error("체인 실패");
return {
route_id: 9,
pipes: { kept: 3, crossing: 1, concave: 2 },
structures: { moved: 1, far: 0 },
cross_dropped: 1,
drainage_stale: true,
};
},
},
};
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);
return box.exports;
}
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const { readState, writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts"));
(async () => {
const out = {};
const draft = {
rev: 3,
nodes: [
[0, 0],
[5, 5],
[10, 0],
],
curveOn: [true, true, true],
};
// 편집기 없음 — 가게 routeDraft 를 보냄 · 없으면 null
out.noneAtAll = await apply.pendingRouteSave("p1");
useDesignStore("p1", null).set("routeDraft", draft);
out.storeOnly = await apply.pendingRouteSave("p1");
// 편집기 등록 — 안 바뀌었으면 null(가게에 초안이 있어도 편집기가 정함) · 바뀌면 계약 꼴
let changed = false;
let kept = 0;
let rebuilt = 0;
apply.bindRouteApply({
projectId: "p1",
nodes: () => ({
planned: [
[0, 0],
[5, 5],
[10, 0],
],
curveOn: [true, false],
curveRadius: [null, 25],
}),
changed: () => changed,
terrain: () => "sheet",
stationSnap: () => true,
initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) },
profileShown: async () => true,
onApplied: () => (rebuilt += 1),
});
out.unchanged = await apply.pendingRouteSave("p1");
changed = true;
const body = await apply.pendingRouteSave("p1");
out.body = body;
// 저장 — 가게 초안 비움 · 결과 캐시 비움 · 추천 다시 적음
writeState("design-stations", [{ k: 1 }], "p1");
const saved = await apply.saveRouteDraft("p1", body);
out.savedRouteId = saved.route_id;
out.storeAfterSave = useDesignStore("p1", null).get("routeDraft");
out.keptAfterSave = kept;
out.summary = apply.routeSaveSummary(saved, 2);
// 저장 뒤 횡단 초안 넘김 차례 — 새 상세 → 측점 열쇠 → 넘김 → 넘긴 고침 저장
calls.length = 0;
out.outside = await apply.carryAfterRouteSave("p1", 7, 9);
out.carryCalls = [...calls];
// 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움
useDesignStore("p1", null).set("routeDraft", draft);
writeState("pipes", [{ chainage_m: 1 }], "p1");
calls.length = 0;
const snapshot = {
planned: [
[1, 1],
[9, 9],
],
curveOn: [true, true],
curveRadius: [null, null],
};
out.recommendOk = await apply.saveRecommendedRoute("p1", snapshot, { mode: "earthwork" }, true);
out.recommendBody = bodies[bodies.length - 1];
out.recommendCalls = calls.filter((c) => !c.startsWith("toast:"));
out.recommendDraftsLeft = {
route: useDesignStore("p1", null).get("routeDraft"),
pipes: readState("pipes", "p1"),
};
out.rebuilt = rebuilt;
// 추천 저장 실패 — false · 오류 한 줄 · 다시 안 세움
failReplan = true;
calls.length = 0;
out.recommendFail = await apply.saveRecommendedRoute("p1", snapshot, null, false);
out.recommendFailCalls = [...calls];
out.rebuiltAfterFail = rebuilt;
console.log(JSON.stringify(out));
})();
@@ -30,7 +30,22 @@ const record = (name, fail) => async () => {
if (fail) throw new Error(`${name} 실패`);
};
let failStructures = false;
// 노선 저장(346-3) — 초안 · 실패를 시험이 정함
let routeBody = null;
let failRoute = false;
const routeStub = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => {
calls.push("route");
if (failRoute) throw new Error("노선 저장 실패");
return { route_id: 9, pipes: { kept: 3, crossing: 1, concave: 0 }, cross_dropped: 1 };
},
carryAfterRouteSave: async (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2),
routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""),
rebuildAfterRouteSave: async () => calls.push("rebuild"),
};
const STUBS = {
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub,
"@ui/ui_template_elements": {
showToast: (text, kind) => calls.push(`toast:${kind}`),
showLoadingOverlay: () => calls.push("overlay:on"),
@@ -161,6 +176,57 @@ async function run() {
});
out.noRouteCalls = [...calls];
// ⑥ 노선이 바뀜(346-3) — 노선 먼저 · 뒤 차례는 새 노선(9) · 화면 몫 건너뜀 · 횡단 초안 넘김 · 다시 세움
b05.replace({ station_offsets: { "30.000": 0.2 }, curve_radii: {} }); // 옛 노선(7) 계획고 초안
routeBody = {
draft: {
rev: 1,
nodes: [
[0, 0],
[10, 0],
],
},
};
calls.length = 0;
out.routeOk = await saveDesign({
projectId: "p1",
routeId: () => 7,
prepare: record("prepare"),
steps: { pipes: record("pipes:screen"), alignment: record("alignment:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
done: async (confirm) => calls.push(`done:${confirm}`),
failed: () => calls.push("failed"),
});
out.routeCalls = [...calls];
// ⑦ 노선 저장 실패 — 거기서 멈춤 · 다른 초안 그대로
b05.replace({ station_offsets: { "50.000": 0.3 }, curve_radii: {} });
failRoute = true;
calls.length = 0;
out.routeFailOk = await saveDesign({
projectId: "p1",
routeId: () => 7,
cross: async (confirm) => calls.push(`cross:${confirm}`),
done: async (confirm) => calls.push(`done:${confirm}`),
failed: () => calls.push("failed"),
});
out.routeFailCalls = [...calls];
out.routeFailDraft = edit.pendingAlignment(7)?.station_offsets ?? null;
failRoute = false;
// ⑧ [확정] 중 노선이 바뀜 — 노선 저장 뒤 다시 세움 · 확정 단계는 안 닫음(새 노선으로 다시)
calls.length = 0;
await saveDesign(
{
projectId: "p1",
routeId: () => 7,
cross: async (confirm) => calls.push(`cross:${confirm}`),
done: async (confirm) => calls.push(`done:${confirm}`),
failed: () => calls.push("failed"),
},
{ confirm: true },
);
out.routeConfirmCalls = [...calls];
routeBody = null;
// ⑤ [초기화] — 초안 · 한 벌 모두 버림
b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} });
edit.clearAlignmentDrafts();
@@ -291,6 +291,7 @@ def test_화면_확인이_고른_지형을_실음():
page = ROOT / "B05_Profile"
apply = (page / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "terrain: params.terrain()," in apply
# 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄
assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply
assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈
assert "analysisTerrain(projectId, designTerrain)" in main # 고른 값 없으면 설계 지형
@@ -0,0 +1,109 @@
"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 추천 [적용] = as_initial 저장 · 저장 뒤 초안 넘김.
노선 이음(`B05_Profile_UI_RouteEdit_Apply`)은 Node 헬퍼 `helper_346_3_route_save.cjs` 가 서버 · 화면
창구만 가짜로 두고 돌림 · 서버 길 시험은 `test_346_3_route_save.py` · 저장 차례(노선 먼저 · 안 바뀌면
안 보냄 · 실패 때 초안 남음)는 `test_design_store_save.py`.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_346_3_route_save.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(run.stdout)
def test_노선_안_바뀜_안_보냄(out):
assert out["noneAtAll"] is None
assert out["unchanged"] is None # 편집기가 정함 — 가게에 초안이 남아 있어도
assert out["storeOnly"]["draft"]["rev"] == 3 # 편집기 없으면 가게 routeDraft
def test_초안은_계약_RouteDraft_꼴(out):
draft = out["body"]["draft"]
assert set(draft) == {
"rev",
"nodes",
"curveOn",
"curveRadius",
"curveLock",
"curveArc",
"stationSnap",
"terrain",
}
n = len(draft["nodes"])
assert all(
len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc")
)
assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None]
assert draft["stationSnap"] is True and draft["terrain"] == "sheet"
assert out["body"]["initial"] == {"mode": "follow"} and out["body"]["balanced_profile"] is True
assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로
def test_저장_뒤_가게_초안_비움_결과_한_줄(out):
assert out["savedRouteId"] == 9
assert out["storeAfterSave"] is None
assert out["keptAfterSave"] == 1
assert out["summary"] == (
"노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · "
"노선 밖 고침 3 건 · 배수유역 낡음"
) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄
def test_초안_넘김_차례(out):
assert out["carryCalls"] == ["invalidate:9", "load:9", "keys:9:9", "carry:7->9", "crossSave:9"]
assert out["outside"] == 2
def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
assert out["recommendOk"] is True
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
def test_추천_저장_실패는_한_줄_다시_안_세움(out):
assert out["recommendFail"] is False
errors = [c for c in out["recommendFailCalls"] if c.startswith("toast:error:")]
assert len(errors) == 1 and "체인 실패" in errors[0]
assert out["rebuiltAfterFail"] == 1
def test_노선_반영_단추_없앰_배선():
chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts")
assert 'data-act="apply"' not in chrome and "노선 반영</button>" not in chrome
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름
save = _read("A00_Common/design/design_save.ts")
assert save.index('"route",') < save.index('"pipes",')
assert "await pendingRouteSave(ctx.projectId)" in save
assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
at = initial.index("onApply: (picked) => {")
assert "saveRecommendedRoute(" in initial[at : at + 900]
replan = _read("B05_Profile/B05_Profile_Api_Replan.ts")
assert "body: JSON.stringify(body)" in replan and "as_initial?: boolean;" in replan
@@ -33,10 +33,12 @@ def test_겉틀은_본문_칸():
assert 'overlay.className = "b05-routeedit";' in chrome
assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome
assert '"tools"' in chrome and "replaceWith(...tools)" in chrome
assert ">반영 전으로</button>" in chrome and ">노선 반영</button>" in chrome
# 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · [취소] = [저장 전으로]
assert ">저장 전으로</button>" in chrome and 'data-act="apply"' not in chrome
assert "노선 반영</button>" not in chrome
assert ">취소</button>" not in chrome and ">확인</button>" not in chrome
# 시험 · 배선이 집는 자리는 남김
for hook in ('data-act="apply"', 'data-act="cancel"', "b05-routeedit__measure-close"):
for hook in ('data-act="cancel"', "b05-routeedit__measure-close"):
assert hook in chrome
assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome
+2 -2
View File
@@ -114,9 +114,9 @@ def test_초기화_계획노선_편집은_다시_세움():
assert step in rebuild
assert rebuild.index("invalidateSectionDetail") < rebuild.index("remountRoute(")
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
# 346-3 — [저장]이 노선을 보낸 뒤 결과 캐시를 비우고 편집기가 건 다시 세우기(rebuildAfterReplan)
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert apply.index("clearDrafts(projectId);") < apply.index("await params.onApplied();")
assert "clearResults(projectId);" in apply
assert "clearResults(projectId);" in apply and "rebuild: params.onApplied," in apply
def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움():
+49 -1
View File
@@ -48,7 +48,15 @@ def test_계획고_가게_한_벌(out):
def test_저장_차례(out):
assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
assert out["order"] == [
"route",
"pipes",
"structures",
"alignment",
"culvertOptions",
"uphill",
"cross",
] # 346-3 노선 먼저
assert out["saveOk"] is True
assert out["saveCalls"] == [
"overlay:on",
@@ -78,3 +86,43 @@ def test_구조물_실패는_알리고_넘어감_확정_노선없음(out):
def test_초기화는_초안과_한_벌을_버림(out):
assert out["cleared"] == {"draft": None, "pending": None}
def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
"""346-3 — 노선 초안이 있으면 맨 앞 · 관 · 구조물 기본 창구 · 계획고 초안(옛 노선 7 칸)을 새 노선 9 로 ·
화면 몫 일(옛 노선 판)은 건너뜀 · 횡단 초안 넘김(측점 열쇠) · 결과 한 줄 · 다시 세움."""
assert out["routeOk"] is True
assert out["routeCalls"] == [
"overlay:on",
"prepare",
"route",
"pipes",
"structures",
'alignment:9:{"30.000":0.2}',
"culvert:aislo:draft:culvertopt:p1:7",
"uphill",
"carry:7->9",
"summary:9:2",
"toast:info",
"done:false",
"rebuild",
"overlay:off",
]
assert "pipes:screen" not in out["routeCalls"] and "alignment:screen" not in out["routeCalls"]
assert "cross:false" not in out["routeCalls"] # 옛 노선 횡단 본문 대신 넘김
def test_노선이_그대로면_안_보냄(out):
assert "route" not in out["saveCalls"]
def test_노선_저장_실패는_멈추고_초안_남음(out):
assert out["routeFailOk"] is False
assert out["routeFailCalls"] == ["overlay:on", "route", "failed", "overlay:off"]
assert out["routeFailDraft"] == {"50.000": 0.3} # 계획고 초안 그대로
def test_확정_중_노선이_바뀌면_다시_세우고_확정은_다시(out):
calls = out["routeConfirmCalls"]
assert calls[1] == "route" and calls[-2:] == ["rebuild", "overlay:off"]
assert "done:true" not in calls and "cross:true" not in calls