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

This commit is contained in:
2026-10-18 16:14:35 +09:00
12 changed files with 375 additions and 79 deletions
+130
View File
@@ -0,0 +1,130 @@
/* PLAN 429 단위검증 헬퍼 — [저장] 길 정합.
* ④ 노선 저장 뒤 횡단 저장 = 고침이 없어도 한 번(`saveCachedCrossPatches`)
* ⑨ 추가 측점 = 계산 묶음 입력(`plusStations` · stations 부터) · 가게 초안 바뀜 → 묶음에 넣음(`feedFacilities`)
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구 · 화면 모듈은 가짜. 짝 = `test_429_save_path.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
globalThis.window = { sessionStorage: null, localStorage: null, setTimeout, clearTimeout };
/** 아무 이름이나 받는 가짜 모듈 — 부르면 undefined. */
const anything = () =>
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
function loader(stubs, real = () => false) {
const cache = new Map();
const load = (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];
const target = path.join(path.dirname(full), `${request}.ts`);
if (request.startsWith(".") && real(target)) return load(target);
return anything();
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
};
return load;
}
async function run() {
const out = {};
// ④ 고침이 없어도 횡단 저장 한 번 — 서버가 지금 종단으로 횡단을 다시 셈
const sent = [];
const store = loader({
"./B06_Section_Api_Fetch": {
saveSections: async (...args) => (sent.push(args.map((a) => a ?? null)), {}),
fetchSectionDetail: async () => null,
},
})(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
out.noPatchReturn = await store.saveCachedCrossPatches("p1", 9);
out.noPatchSent = sent;
// ⑨ 묶음 — 추가 측점이 바뀌면 stations 부터 · 같은 값이면 안 셈
const DESIGN = path.join(ROOT, "A00_Common", "design");
const pipeMod = loader({}, () => true)(path.join(DESIGN, "logic", "design_pipeline.ts"));
out.stationsDeps = pipeMod.STAGE_DEPS.stations;
const ran = [];
const stages = {};
for (const name of [...pipeMod.BROWSER_STAGES, ...pipeMod.AFTER_STAGES])
stages[name] = (ctx) => (ran.push(name), { name, plus: ctx.inputs.plusStations ?? null });
stages.follow = () => (ran.push("follow"), null); // 입력 고침 없음
const pipe = pipeMod.createDesignPipeline({ stages });
await pipe.start({
route: null,
alignment: null,
cross: null,
pipes: [],
structures: [],
plusStations: [],
});
ran.length = 0;
await pipe.update("plus", [40]);
out.plusRan = [...ran];
out.plusSeen = pipe.output("stations")?.plus ?? null;
ran.length = 0;
out.sameValue = pipe.update("plus", [40]); // 같은 값 = 차례 안 띄움(null)
await new Promise((resolve) => setTimeout(resolve, 0));
out.sameRan = [...ran];
// ⑨ 먹이기 — 가게 추가 측점 초안이 바뀌면 묶음에 · 비면 저장분 · 되돌리기는 입력과 견줘 다를 때만
let restore = null;
const listeners = new Map();
const values = new Map();
const fakeStore = {
get: (key) => values.get(key) ?? null,
set: (key, value) => (
values.set(key, value),
(listeners.get(key) ?? []).forEach((fn) => fn(value))
),
subscribe: (key, fn) => {
listeners.set(key, [...(listeners.get(key) ?? []), fn]);
return () => undefined;
},
};
const fed = [];
const inputNow = { plusStations: [12] };
const feeder = loader({
"../../B05_Profile/B05_Profile_UI_Profile_History": {
onPageHistory: (hooks) => ((restore = hooks.restore), () => undefined),
},
"./design_save": { savingDesign: () => false },
})(path.join(DESIGN, "design_pipeline_facilities.ts"));
feeder.feedFacilities({
projectId: "p1",
store: fakeStore,
ready: () => true,
gone: () => false,
saved: (key) => (key === "plusStations" ? [12] : []),
input: (key) => inputNow[key] ?? null,
routed: () => false,
feed: (source, value) => fed.push([source, value]),
resync: async () => undefined,
});
fakeStore.set("plusStations", [12, 30]);
fakeStore.set("plusStations", null); // 초안 비움 = 저장분
out.fed = [...fed];
fed.length = 0;
values.set("plusStations", [12, 55]); // 되돌리기 = 세션만 되씀(알림 없음)
restore?.();
out.restoreFed = fed.filter(([source]) => source === "plus");
return out;
}
run().then(
(out) => console.log(JSON.stringify(out)),
(error) => {
console.error(error);
process.exit(1);
},
);
@@ -161,16 +161,16 @@ async function run() {
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
out.order = DESIGN_SAVE_ORDER;
// ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감
// ④ 구조물이 실패하면 그 차례에서 멈춤 · 차례 이름째 알림(429 ⑨) · 노선 없으면 안 나감
calls.length = 0;
failStructures = true;
await saveDesign(
out.tolerantOk = await saveDesign(
{
projectId: "p1",
routeId: () => 7,
steps: { uphill: record("uphill:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
failed: () => calls.push("failed"),
failed: (error) => (calls.push("failed"), (out.tolerantError = error.message)),
},
{ confirm: true },
);
+144
View File
@@ -0,0 +1,144 @@
"""PLAN 429 — [저장] 길 정합(분석 424 9장 ④⑤⑧⑨).
- ④ 노선 [저장] 뒤 횡단 저장 = 고침이 없어도 한 번(서버가 지금 종단으로 횡단을 다시 셈 · 뒤에
나간 계획고 초안이 횡단 설계 높이에 실림) · 서버 [저장]이 횡단 설계 높이를 저장 종단에서 냄.
- ⑤ 상단측 차례(`sync_uphill_overrides_into_designs`)가 소단 · 깎기 경사 · 조절 값을 남김.
- ⑨ 추가 측점 = 계산 묶음 입력(stations 부터 다시) · 가게 초안 바뀜 · 되돌리기 → 묶음에 넣음.
[저장] 차례 실패 = 그 차례에서 멈춤(`test_design_store_save.py`).
Node 헬퍼 = `helper_429_save_path.cjs`.
"""
import asyncio
import json
import subprocess
from pathlib import Path
import pytest
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Router_Design import enforce_design_lines
from config.config_system import STANDARD_CROSS_SECTION
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", str(HERE / "helper_429_save_path.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _ground() -> list[dict]:
"""왼쪽이 높은 비탈 — 왼쪽 깎기 · 오른쪽 쌓기(편절)."""
return [
{"offset_m": o / 2, "elevation_m": round(100.0 + 0.25 * o / 2, 4), "valid": True}
for o in range(-40, 41)
]
# ── ④ ────────────────────────────────────────────────────────────────────────
def test_노선_저장_뒤_고침이_없어도_횡단_저장_한_번(out):
assert out["noPatchSent"] == [["p1", 9, None, None]] # 본문 없이 = 서버가 다시 셈만
assert out["noPatchReturn"] == 0
def test_서버_저장이_횡단_높이를_저장_종단에서(tmp_path):
"""초안 계획고(101.2)가 종단 정본에 나간 뒤 [저장] 다시 셈 = 그 높이 · 옛 설계(100.0) 아님."""
old = compute_cross_design(
_ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil",
section_mode="left_cut", ditch_side="left",
) # fmt: skip
longitudinal = {
"design_profiles": [
{"samples": [{"chainage_m": 0.0, "elevation_m": 101.2},
{"chainage_m": 40.0, "elevation_m": 101.2}]}
]
} # fmt: skip
sections = [{"chainage_m": 20.0, "samples": _ground(), "design": old}]
assert enforce_design_lines(longitudinal, sections, tmp_path, STANDARD_CROSS_SECTION) == 1
assert sections[0]["design"]["design_elevation_m"] == pytest.approx(101.2, abs=1e-6)
# ── ⑤ ────────────────────────────────────────────────────────────────────────
def test_상단측_차례가_소단_깎기경사_조절값_남김(tmp_path, monkeypatch):
import B05_Profile.B05_Profile_Router_Confirm as confirm
import B06_Section.B06_Section_Repository as repo
import B06_Section.B06_Section_Repository_Bulk as bulk
import B06_Section.B06_Section_Router_Design as design_mod
user = {
"cut_slope_ratio_user": 0.7,
"berm": {"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0},
"berm_depth_left_m": 2.0,
"basin_adjust": {"w": 1.5},
"structure_note": "글",
"shoulder_extend_right_m": 0.3,
}
before = compute_cross_design(
_ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil",
section_mode="right_cut", ditch_side="right",
) # fmt: skip
stored = {**before, **user}
long_file = tmp_path / "long.json"
long_file.write_text("{}", encoding="utf-8")
async def designs(_connection, _route_id):
return [{"chainage_m": 20.0, "design": stored}]
async def longitudinal(*_args):
return {"data": {"options": {"standard_cross_section": STANDARD_CROSS_SECTION}}}
written: list = []
async def merge(_connection, *, route_id, entries, replace, project_id=None):
written.extend(entries)
monkeypatch.setattr(repo, "get_cross_section_designs", designs)
monkeypatch.setattr(confirm, "get_longitudinal_section", longitudinal)
monkeypatch.setattr(bulk, "merge_cross_section_designs", merge)
monkeypatch.setattr(design_mod, "resolve_longitudinal_path", lambda *_: long_file)
monkeypatch.setattr(
design_mod, "read_cross_design_inputs", lambda *_a: (_ground(), 100.0, False, {})
)
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda _root: [])
asyncio.run(
confirm.sync_uphill_overrides_into_designs(
None, "p", 1, tmp_path, "long.json", [{"chainage_m": 20.0, "side": "left"}]
)
)
assert len(written) == 1
chainage, after = written[0]
assert chainage == 20.0 and before["ditch_type"] is None # 측구 끔 저장분도 셈
for key, value in user.items():
assert after[key] == value, key # 값 남음
assert after["cut_slope_ratio"] == pytest.approx(0.7) # 깎기 경사가 기하에 실림(키만 아님)
assert after["shoulder_extend_right_m"] == pytest.approx(0.3)
# ── ⑨ ────────────────────────────────────────────────────────────────────────
def test_추가_측점이_묶음_입력(out):
assert "input.plusStations" in out["stationsDeps"]
assert out["plusRan"][0] == "stations" and "route" not in out["plusRan"] # stations 부터
assert "cross" in out["plusRan"] # 뒤 단계까지 이어짐
assert out["plusSeen"] == [40]
assert out["sameValue"] is None and out["sameRan"] == [] # 같은 값은 안 셈
def test_가게_추가_측점_초안이_묶음에(out):
assert out["fed"] == [["plus", [12, 30]], ["plus", [12]]] # 초안 · 비우면 저장분
assert out["restoreFed"] == [["plus", [12, 55]]] # 되돌리기 = 입력과 다르면 넣음
+2 -2
View File
@@ -77,8 +77,8 @@ def test_저장이_창구를_다섯_다_거친다():
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)가 줄었음"
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게).
assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐"
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
order = order[: order.index("]")]
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
+8 -9
View File
@@ -3,8 +3,8 @@
- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽.
- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠).
- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 ·
계획고는 실패해도 다음 차례로.
- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 어느 차례든
실패하면 거기서 멈추고 차례 이름째 알림(429 ⑨).
Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
@@ -76,13 +76,12 @@ def test_저장_차례(out):
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
def test_구조물_실패는_멈추고_알림_노선없음(out):
"""429 ⑨ — 차례가 실패하면 뒤 차례(상단측 · 횡단)를 안 보냄 · 차례 이름째 알림."""
assert out["tolerant"] == ["overlay:on", "pipes", "structures", "failed", "overlay:off"]
assert out["tolerantOk"] is False
assert out["tolerantError"].startswith("구조물 차례에서 멈췄습니다")
assert "structures 실패" in out["tolerantError"] # 까닭 그대로
assert out["noRouteSave"] is False and out["noRouteCalls"] == []