Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_3', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_4

This commit is contained in:
2026-10-18 16:13:48 +09:00
42 changed files with 1502 additions and 215 deletions
@@ -159,9 +159,13 @@ Promise.resolve(
out.second = { count: snap.snapOldStations(again), toasts: [...toasts] };
// ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘)
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const gone = [
{ id: "pipe-80.17", chainage_m: 80.17 },
@@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) {
return box.exports;
}
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
+5 -1
View File
@@ -21,9 +21,13 @@ function loadTs(file, stubs = {}) {
}
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
@@ -20,6 +20,7 @@ const STUBS = {
},
centerDirectionOf: () => null,
},
"@ui/ui_template_elements": { showToast: () => {} }, // 곡선 삭제 경고(430)
};
function loadTs(file) {
@@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) {
return box.exports;
}
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
@@ -0,0 +1,189 @@
/* 426 종단 직선 판정 한 벌 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · `_Straighten.ts` ·
* `_Pivot.ts` · `_Profile_Straight.ts` · `design_profile_free.ts` 를 그 자리에서 트랜스파일해 돌린다.
* 붙은 측점(100/100.4 · 120/120.25) 직선화 · 끝점 꼭짓점 아님 · 자유지정 R · 기울이기 누름 쌓임 ·
* 편집 측점 수 · 0 오프셋 정리 + 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
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 (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 LOGIC = path.join(ROOT, "B05_Profile", "logic");
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
const pivot = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Pivot.ts"));
const judge = loadTs(path.join(LOGIC, "B05_Profile_Profile_Straight.ts"));
const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts"));
const policy = {
station_interval_m: 20,
curve_radius_ratio: 1,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: false,
default_curve_length_m: 15,
default_curve_radius_m: 300,
balance_tolerance_percent: 10,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 0,
paved: true,
};
const regular = Array.from({ length: 11 }, (_, i) => i * 20);
const groundAt = (s) => 100 + 0.015 * s + 0.4 * Math.sin(s / 7) + 0.0371 * Math.cos(s * 3.1);
const all = [...regular, 100.4, 120.25].sort((a, b) => a - b);
/** 붙은 측점까지 꼭짓점(A) · 정규 측점만 꼭짓점(B). */
function makeBase(pviS) {
return {
policy,
onlyExplicitCurves: false,
basePvi: pviS.map((s) => ({ chainage_m: s, elevation_m: Number(groundAt(s).toFixed(6)) })),
chainage: all,
ground: all.map((s) => Number(groundAt(s).toFixed(6))),
stations: all.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
}
const baseA = makeBase(all);
const baseB = makeBase(regular);
const empty = align.emptyEdits();
const build = (base, edits) => align.buildAlignment(base, edits);
const curvesIn = (alignment, from, to) =>
alignment.curves
.filter((c) => c.chainage_m > from + 1e-6 && c.chainage_m < to - 1e-6)
.map((c) => c.chainage_m);
const planAt = (alignment, s) => align.planElevationAt(alignment, s);
const r6 = (v) => Number(v.toFixed(6));
// ① 붙은 측점 직선화 40 → 140 (A)
const sA = straight.straightenBetween(baseA, empty, 40, 140);
const alA = build(baseA, sA);
// 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 안쪽 꼭짓점 수 — 시험이 반올림 흠을 실제로 거치는지
let noiseBends = 0;
alA.pvi.forEach((node, i) => {
if (i === 0 || i === alA.pvi.length - 1) return;
if (node.chainage_m <= 40 + 1e-6 || node.chainage_m >= 140 - 1e-6) return;
const g = (a, b) => (b.elevation_m - a.elevation_m) / (b.chainage_m - a.chainage_m);
if (Math.abs(g(node, alA.pvi[i + 1]) - g(alA.pvi[i - 1], node)) >= 1e-9) noiseBends += 1;
});
const runA = straight.detectStraightRun(alA, 70);
const axesA = pivot.pivotAxes(alA, 80);
const upA = align.adjustStation(baseA, sA, 80, 0.1);
const alUp = build(baseA, upA);
const gradesUp = alUp.segments
.filter((seg) => seg.from_m >= 40 - 1e-6 && seg.to_m <= 140 + 1e-6)
.map((seg) => r6(seg.grade_percent));
const straightened = {
noiseBends,
curvesInside: curvesIn(alA, 40, 140),
radiiKeys: Object.keys(sA.curve_radii),
run: runA ? [runA.fromM, runA.toM] : null,
axes: axesA ? [axesA.fromM, axesA.toM] : null,
pressRise: r6(planAt(alUp, 80) - planAt(alA, 80)),
curvesAfterPress: curvesIn(alUp, 40, 140),
gradesUp,
editedCount: judge.bentEditCount(alA),
offsetCount: Object.keys(sA.station_offsets).length,
};
// ② 끝점 꼭짓점 아님(B) — 100.4 → 160 직선화 · 120.25 → 180 자유지정
const alB = build(baseB, empty);
const sB = straight.straightenBetween(baseB, empty, 100.4, 160);
const alSB = build(baseB, sB);
const line = (a, b, s) => a[1] + ((b[1] - a[1]) * (s - a[0])) / (b[0] - a[0]);
const endA = [100.4, planAt(alB, 100.4)];
const endB = [160, align.controlElevationAt(alB, 160)];
const endpoint = {
// 끝 꼭짓점 = 직선화 전 그 자리 계획고(꼭짓점 높이 · 그 라운드는 직선의 접선)
endHeld: r6(align.controlElevationAt(alSB, 100.4) - planAt(alB, 100.4)),
endIsVertex: alSB.pvi.some((n) => Math.abs(n.chainage_m - 100.4) < 1e-6),
curvesInside: curvesIn(alSB, 100.4, 160),
innerResidual: [120, 120.25, 140].map((s) => r6(planAt(alSB, s) - line(endA, endB, s))),
};
const z140 = planAt(alB, 140) + 1;
const fEdits = free.buildFreeEdits(baseB, empty, [
{ chainage_m: 120.25, elevation_m: align.controlElevationAt(alB, 120.25) },
{ chainage_m: 140, elevation_m: z140 },
{ chainage_m: 180, elevation_m: align.controlElevationAt(alB, 180) },
]);
const alF = build(baseB, fEdits);
// 자유지정 뒤 ▼ 로 140 을 다시 120.25 · 180 잇는 선 위로 → 직선 → 정리하면 R 없음
const back =
line(
[120.25, align.controlElevationAt(alF, 120.25)],
[180, align.controlElevationAt(alF, 180)],
140,
) - planAt(alF, 140);
const downF = align.adjustStation(baseB, fEdits, 140, back);
const tidyF = straight.tidyEdits(baseB, downF);
const alTidyF = build(baseB, tidyF);
const freeCase = {
endHeld: r6(align.controlElevationAt(alF, 120.25) - align.controlElevationAt(alB, 120.25)),
radiusAt140: fEdits.curve_radii["140.000"] ?? null,
curveAt140: alF.curves.some((c) => Math.abs(c.chainage_m - 140) < 1e-6),
curvesInsideF: curvesIn(alF, 120.25, 180),
radiusKeptRaw: downF.curve_radii["140.000"] !== undefined,
radiusAfterTidy: tidyF.curve_radii["140.000"] ?? null,
curvesAfterTidy: curvesIn(alTidyF, 120.25, 180),
sameShape: r6(planAt(alTidyF, 140) - planAt(build(baseB, downF), 140)),
};
// ③ 고른 직선 기울이기 — 누름 쌓임 · 되돌아오면 R 없음 (A)
const run = straight.detectStraightRun(alA, 70);
let e = sA;
const rises = [];
const pivotM = 80; // 40 · 140 가운데 = 90 → 가장 가까운 안쪽 꼭짓점(80 · 100 중 앞선 80)
[0.1, 0.1, -0.1, -0.1].forEach((d) => {
e = straight.tiltStraightRun(baseA, e, run, d);
rises.push(r6(align.controlElevationAt(build(baseA, e), pivotM) - planAt(alA, pivotM)));
});
const tiltOnce = straight.tiltStraightRun(baseA, sA, run, 0.1);
const tilt = {
rises,
finalRadii: Object.keys(e.curve_radii),
finalCurves: curvesIn(build(baseA, e), 40, 140),
editedAfterOne: judge.bentEditCount(build(baseA, tiltOnce)),
};
// ④ 0 오프셋 정리
const tidyZero = straight.tidyEdits(baseB, {
station_offsets: { "60.000": 0, "80.000": 0.3 },
curve_radii: {},
});
console.log(
JSON.stringify({
straightened,
endpoint,
freeCase,
tilt,
tidyZero: Object.keys(tidyZero.station_offsets),
mirror: {
base: baseA,
cases: [
{ edits: sA, curves: alA.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) },
{ edits: upA, curves: alUp.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) },
{
edits: tiltOnce,
curves: build(baseA, tiltOnce).curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]),
},
],
},
}),
);
+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);
},
);
@@ -0,0 +1,137 @@
/* 430 평면 형상 제어 규칙 한 곳 — `logic/B05_Profile_UI_RouteEdit_Edits.ts`(하한 검사 · 겹친 노드 ·
* 못 그린 곡선 · 넣은 값 되살림) · `_KeyNudge.fitNote` 를 Node 로 돌려 JSON 한 줄
* (`test_430_route_edit_rules.py` 가 판정).
* node helper_430_route_edit_rules.cjs <서버 곡선표 JSON 파일>
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
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) =>
request.startsWith(".")
? loadTs(path.join(path.dirname(full), `${request}.ts`))
: require(request);
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts"));
const nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts"));
const LIMITS = { minRadiusM: 30, limitRadiusM: 20, limitArcM: 0 };
// IP1 · IP2 · IP3 — 직선 넉넉(곡선 R 30 다 들어감)
const ROUTE = [
[0, 0],
[100, 0],
[200, 60],
[300, 0],
[400, 0],
];
function cols(planned) {
const n = planned.length;
return {
planned: planned.map((v) => [v[0], v[1]]),
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
apexLock: Array(n).fill(false),
};
}
/** 지난 걸음 `before` 에서 `change` 로 고친 뒤 하한 검사 글. */
function stop(before, change) {
const now = cols(before.planned);
change(now);
const built = edits.rebuildLine(now, LIMITS);
return edits.floorStop(before, now.planned, built.nodes, LIMITS);
}
const base = cols(ROUTE);
const out = {
// 같은 노드 수 — IP2 를 IP1 쪽 8 m 앞으로(IP1 · IP2 접선 자리 모자람 → R 하한 아래)
moveBreak: stop(base, (c) => (c.planned[2] = [108, 4])),
moveOk: stop(base, (c) => (c.planned[2] = [200, 70])),
// 끼우기 — IP1 바로 뒤 4 m 에 새 노드(IP1 접선 자리 2 m)
insertBreak: stop(base, (c) => {
c.planned.splice(2, 0, [104, 1]);
for (const k of ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"])
c[k].splice(2, 0, k === "curveOn" ? true : k === "apexLock" ? false : null);
}),
// 지우기 — IP2 를 빼도 IP1 · IP3 넉넉
deleteOk: stop(base, (c) => {
for (const k of ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"])
c[k].splice(2, 1);
}),
// 둘 넘게 다르면 안 견줌
manyGap: stop(base, (c) => {
c.planned.splice(1, 0, [50, 0], [52, 0]);
for (const k of ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"])
c[k].splice(
1,
0,
...(k === "curveOn" ? [true, true] : k === "apexLock" ? [false, false] : [null, null]),
);
}),
};
// 이미 하한을 밑돌던 자리는 「새로」가 아님 — 밑돈 노선에서 다시 조금 옮겨도 통과
const broken = cols(ROUTE);
broken.planned[2] = [108, 4];
out.alreadyBroken = stop(broken, (c) => (c.planned[2] = [108.5, 4.2]));
// 겹친 노드 · 못 그린 곡선
const overlap = [
[0, 0],
[100, 0],
[100.3, 0.2],
[200, 60],
[300, 0],
];
out.flags = edits.routeFlags(overlap, Array(5).fill(true), []);
out.flagsOff = edits.routeFlags(ROUTE, [true, false, false, false, true], []);
out.summary = edits.curveSummary({
nodeCount: 5,
curveOn: Array(5).fill(true),
curveRadius: Array(5).fill(null),
curveLock: Array(5).fill(null),
apexLock: [],
curveCount: 2,
violationCount: 0,
minRadiusM: 30,
fresh: false,
flags: out.flags,
});
// fitNote — 손으로 옮긴 고른 IP 는 「밀림」에 안 셈(놓을 때 · 430) · 입력 뒤는 셈(408-1)
const after = ROUTE.map((v, i) => (i === 2 ? [v[0] + 3, v[1]] : i === 3 ? [v[0] + 1, v[1]] : v));
out.noteHand = nudge.fitNote(ROUTE, after, 2, [], 20, 20, true);
out.noteInput = nudge.fitNote(ROUTE, after, 2, [], 20, 20);
// 짝 — 서버 곡선표(파이썬이 씀)를 그대로 펴서 넣은 값이 돌아오는가
const server = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const flat = edits.flattenServerPlan(server.nodes, server.curves, server.edits);
out.flat = {
planned: flat.planned,
curveOn: flat.curveOn,
curveRadius: flat.curveRadius,
curveLock: flat.curveLock,
curveArc: flat.curveArc,
};
const old = edits.flattenServerPlan(server.nodes, server.curves, null);
out.flatOld = { curveRadius: old.curveRadius, curveLock: old.curveLock };
process.stdout.write(JSON.stringify(out));
@@ -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 },
);
@@ -49,6 +49,7 @@ def test_편집기_배선() -> None:
assert main.count("setAnalysisTerrain(") == 1 # 바꿀 때 한 곳뿐 — 열 때 쓰기 없음
handler = main.split('terrainSelect.addEventListener("change"', 1)[1][:900]
assert "setAnalysisTerrain(projectId" in handler
assert "options.draft?.edited(" in handler # 바꾸면 계산 묶음에 알림
assert "tellDraft()" in handler # 바꾸면 계산 묶음에 알림(430 — 스냅과 한 곳)
assert "options.draft?.edited(" in main.split("function tellDraft()", 1)[1][:400]
# 설계 지형이 수치지형도면 확정 모델(수치지형도 서피스) 등고선을 「LAS」로 쓰지 않음
assert 'analysisTerrain(projectId) === "las" && designTerrain === "las"' in main
@@ -58,6 +58,7 @@ def test_간격별_상태줄_글(texts, step, want) -> None:
def test_편집기는_고른_간격_글을_씀() -> None:
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "일반측점에 맞췄" not in edit
assert edit.count("applyEdit(snap.text())") == 2
assert edit.count("applyEdit(snap.text())") == 1 # 켬 · 간격 바꿈
assert 'applyEdit(stationSnap ? snap.text() : "옮겼습니다.")' in edit # 놓을 때(430)
snap = SNAP.read_text(encoding="utf-8")
assert "text: () => snapFitText(select.value)" in snap
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""426 B05 종단 직선 판정 한 벌 — 높이 기준(1e-5 m) · 끝점 꼭짓점 · 누름 쌓임 · 편집 측점 수.
요구 259.
계산(`logic/B05_Profile_Profile_Straight.ts` · `_Alignment.ts` buildCurves · `_Pivot.ts` ·
`_Straighten.ts` · `design_profile_free.ts`)은 Node 헬퍼(`helper_426_profile_straight.cjs`)가
트랜스파일해 돌리고, 파이썬 짝(`B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend`)은
같은 base · 편집으로 곡선 자리 · R 이 같은지 거울로 본다.
./venv/Scripts/python.exe -m pytest resources/tester/test_426_profile_straight.py -q
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
STRAIGHT_HEIGHT_M,
AlignmentPolicy,
build_alignment,
is_straight_bend,
)
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_426_profile_straight.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_붙은_측점_직선화_뒤_R_없음_한_직선(out):
got = out["straightened"]
# 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 반올림 흠이 실제로 있음 — 시험이 그 길을 거침
assert got["noiseBends"] > 0
assert got["curvesInside"] == [] and got["radiiKeys"] == []
assert got["run"] == [40, 140]
assert got["axes"] == [40, 140]
def test_직선_안_누름_톱니_없음(out):
got = out["straightened"]
assert got["pressRise"] == pytest.approx(0.1, abs=1e-4)
assert got["curvesAfterPress"] == [80] # 누른 점 하나만 꺾임
left, right = got["gradesUp"][:2], got["gradesUp"][2:]
assert max(left) - min(left) < 1e-3 and max(right) - min(right) < 1e-3 # 0.4 m 조각 톱니 없음
def test_편집_측점_수는_꺾인_꼭짓점(out):
assert out["straightened"]["offsetCount"] > 0
assert out["straightened"]["editedCount"] == 0
assert out["tilt"]["editedAfterOne"] == 1
def test_끝점_꼭짓점_아니면_지금_높이로_세움(out):
got = out["endpoint"]
assert got["endHeld"] == pytest.approx(0, abs=1e-6)
assert got["endIsVertex"]
assert got["curvesInside"] == []
assert all(abs(value) < 1e-5 for value in got["innerResidual"])
def test_자유지정_R_뒤_직선되면_R_지움(out):
got = out["freeCase"]
assert got["endHeld"] == pytest.approx(0, abs=1e-6)
assert got["radiusAt140"] and got["curveAt140"] and got["curvesInsideF"] == [140]
assert got["radiusKeptRaw"] # ▼ 만으로는 기억 R 이 남음 → 적용 한 곳의 정리가 지움
assert got["radiusAfterTidy"] is None and got["curvesAfterTidy"] == []
assert got["sameShape"] == pytest.approx(0, abs=1e-9)
def test_고른_직선_기울이기_누름_쌓임(out):
got = out["tilt"]
assert got["rises"] == pytest.approx([0.1, 0.2, 0.1, 0.0], abs=1e-6)
assert got["finalRadii"] == [] and got["finalCurves"] == []
def test_0_오프셋_정리(out):
assert out["tidyZero"] == ["80.000"]
def test_파이썬_짝_같은_기준():
assert STRAIGHT_HEIGHT_M == 1e-5
assert is_straight_bend(2.5e-6, 0.4, 20.0) # 반올림 흠(0.4 m 조각) = 직선
assert not is_straight_bend(1e-3, 20.0, 20.0) # 0.1 % 꺾임 = 꺾임
assert "STRAIGHT_HEIGHT_M = 1e-5" in _read("B05_Profile/logic/B05_Profile_Profile_Straight.ts")
def test_파이썬_거울_곡선_자리_R(out):
base = out["mirror"]["base"]
policy = AlignmentPolicy.from_dict(base["policy"])
for case in out["mirror"]["cases"]:
got = build_alignment(
base_s=np.array([n["chainage_m"] for n in base["basePvi"]]),
base_z=np.array([n["elevation_m"] for n in base["basePvi"]]),
chainage=np.array(base["chainage"]),
ground=np.array(base["ground"]),
stations=base["stations"],
policy=policy,
edits=case["edits"],
)
py = [[c["chainage_m"], c["r_m"]] for c in got["curves"]]
assert [c[0] for c in py] == pytest.approx([c[0] for c in case["curves"]], abs=1e-6)
assert [c[1] for c in py] == pytest.approx([c[1] for c in case["curves"]], rel=1e-6)
def test_부르는_곳():
assert "tidyEdits(base, next)" in _read("A00_Common/design/design_profile.ts")
assert "bentEditCount(alignment)" in _read("B05_Profile/B05_Profile_UI_Profile_Balance.ts")
assert "isStraightBend(delta, spanLeft, spanRight)" in _read(
"B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"
)
assert "is_straight_bend(delta," in _read("B05_Profile/B05_Profile_Engine_Grade_Alignment.py")
+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]]] # 되돌리기 = 입력과 다르면 넣음
@@ -0,0 +1,185 @@
"""430 평면 형상 제어 규칙 한 곳 — 하한 검사(`applyEdit` 한 곳 · 끼우기 · 지우기 · 손잡이) ·
곡선 삭제 155° 경고 · 놓을 때 맞춤 보고 · 스냅 끔 · 간격 즉시 계산 줄 · R · 길이 고정 ·
넣은 R 곡선표 저장(짝) · 겹친 노드 · 못 그린 곡선 표시.
TS 셈은 Node(`helper_430_route_edit_rules.cjs`) · 짝 = 파이썬 `_write_planned_polyline` 이 쓴
곡선표를 TS `flattenServerPlan` 이 펴서 넣은 값이 돌아오는가.
./venv/Scripts/python.exe -m pytest resources/tester/test_430_route_edit_rules.py -q
"""
import csv
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits # noqa: E402
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_430_route_edit_rules.cjs")
# IP2 뒤 직선 16 m — 넣은 R 200 은 접선 자리에 눌려 그려짐 · IP3 바로 뒤 0.3 m 겹친 점
NODES = [(0.0, 0.0), (120.0, 0.0), (220.0, 70.0), (236.0, 70.0), (236.2, 70.2), (340.0, 0.0)]
FLAGS = [True, True, True, False, True, True]
RADII = [None, 40.0, 200.0, None, None, None]
LOCKS = [None, "radius", "arc", None, "radius", "bogus"]
ARCS = [None, None, 33.5, None, None, None]
@pytest.fixture(scope="module")
def saved(tmp_path_factory) -> dict:
path = tmp_path_factory.mktemp("route") / "planned_route.csv"
replan._write_planned_polyline(
path,
NODES,
30.0,
simplify=False,
curve_flags=FLAGS,
radii=RADII,
locks=LOCKS,
arcs=ARCS,
)
with replan._nodes_path(path).open(encoding="utf-8") as handle:
nodes = [(float(row["x"]), float(row["y"])) for row in csv.DictReader(handle)]
outline = build_planned_polyline(nodes, min_radius_m=30.0, simplify=False)
return {
"path": path,
"nodes": [node.as_dict() for node in outline.nodes],
"curves": replan._read_curves(path),
"edits": replan._read_curves(path, "edits"),
}
@pytest.fixture(scope="module")
def got(saved, tmp_path_factory) -> dict:
server = tmp_path_factory.mktemp("server") / "plan.json"
payload = {key: saved[key] for key in ("nodes", "curves", "edits")}
server.write_text(json.dumps(payload), encoding="utf-8")
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER), str(server)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
# ── 하한 검사 한 곳 ──────────────────────────────────────────────────────────
def test_옮겨_새로_깨면_까닭(got) -> None:
assert got["moveBreak"].startswith("하한에 걸려 되돌렸습니다 — IP1")
assert "(곡선반지름 20m)" in got["moveBreak"]
assert got["moveOk"] == ""
def test_끼우기_새로_깨면_까닭_지우기_넉넉하면_통과(got) -> None:
assert "IP1" in got["insertBreak"] and "IP2" in got["insertBreak"]
assert got["deleteOk"] == ""
assert got["manyGap"] == "" # 둘 넘게 다르면 안 견줌
def test_이미_밑돈_자리는_새로가_아님(got) -> None:
assert got["alreadyBroken"] == ""
# ── 겹친 노드 · 못 그린 곡선 ─────────────────────────────────────────────────
def test_겹친_노드_못_그린_곡선(got) -> None:
assert got["flags"] == {"overlap": [2], "undrawn": [1, 3]}
assert got["flagsOff"] == {"overlap": [], "undrawn": []} # 곡선 끈 자리는 「못 그림」 아님
assert "겹친 노드 IP2(0.5 m 안 · 선에서 빠짐)" in got["summary"]
assert "못 그린 곡선 IP1 · IP3" in got["summary"]
def test_놓을_때_손으로_옮긴_IP_는_밀림에_안_셈(got) -> None:
assert got["noteHand"] == " · 함께 밀린 교점 1곳"
assert got["noteInput"] == " · 함께 밀린 교점 2곳"
# ── 넣은 R · 고정 곡선표 저장(짝) ────────────────────────────────────────────
def test_곡선표_edits_는_저장_노드와_자리가_같음(saved) -> None:
assert len(saved["edits"]) == len(saved["nodes"]) == len(NODES) - 1 # 겹친 점 하나 빠짐
assert saved["edits"][2] == {"curve": True, "radius_m": 200.0, "lock": "arc", "arc_m": 33.5}
assert saved["edits"][1]["lock"] == "radius"
assert saved["edits"][3]["curve"] is False
assert saved["edits"][4]["lock"] is None # 모르는 고정 값은 버림
def test_다시_열면_넣은_R_고정이_돌아옴(got, saved) -> None:
flat = got["flat"]
drawn = next(c["radius_m"] for c in saved["curves"] if c["node_first"] == 2)
assert drawn < 200 - 1 # 그린 R 은 눌림
assert flat["curveRadius"][1:3] == [40, 200] # 넣은 값
assert flat["curveLock"][1:3] == ["radius", "arc"]
assert flat["curveArc"][2] == 33.5
assert flat["curveOn"][3] is False
assert got["flatOld"]["curveRadius"][2] == pytest.approx(drawn, abs=0.01) # 옛 곡선표 = 그린 R
def test_node_edits_짧은_칸은_기본값() -> None:
edits = node_edits([(0, 0), (10, 0), (20, 5)], None, [None, 15.0])
assert edits[1] == {"curve": True, "radius_m": 15.0, "lock": None, "arc_m": None}
assert edits[2] == {"curve": True, "radius_m": None, "lock": None, "arc_m": None}
def test_편집값_없이_쓴_곡선표는_edits_없음(tmp_path) -> None:
path = tmp_path / "planned_route.csv"
replan._write_planned_polyline(path, NODES, 30.0, simplify=False)
assert replan._read_curves(path, "edits") == []
# ── 배선 ────────────────────────────────────────────────────────────────────
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_편집기_배선() -> None:
main = _read("B05_Profile_UI_RouteEdit.ts")
apply_edit = main.split("function applyEdit(", 1)[1][:700]
assert "edits.floorStop(before, planned, nodeInfo, fitLimits())" in apply_edit
assert "restoreSnapshot(before, stop, false)" in apply_edit
assert "curveBar.noteFit(snap.text(), before, index)" in main # 놓을 때 맞춤 보고
snap = main.split("bindStationSnap(chrome", 1)[1][:400]
assert "tellDraft();" in snap # 끔 · 간격 바꿈 즉시 계산 줄
assert "snap.changed()" in main.split("function tellDraft()", 1)[1][:400]
assert "plan.node_edits" in main
assert "nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc })" in main
assert len(main.splitlines()) <= 695
def test_손잡이_끌기_하한_검사() -> None:
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
handle = pointer.split("// 곡선 시작·끝점을 끈다", 1)[1][:2200]
assert "shortfallCrossed(" in handle
assert "params.commit(dragHandle?.node ?? dragNode)" in pointer
def test_곡선_삭제_155_경고() -> None:
bar = _read("B05_Profile_UI_RouteEdit_CurveBar.ts")
on = bar.split("onCurveOn:", 1)[1][:900]
assert "!curveOptionalAt(inner)" in on and '"warning"' in on
def test_저장에_고정_실음_읽기에_node_edits() -> None:
apply = _read("B05_Profile_UI_RouteEdit_Apply.ts")
assert "nodes.curveLock?.[index] ?? null" in apply
assert "nodes.curveArc?.[index] ?? null" in apply
router = _read("B05_Profile_Router_Replan.py")
assert '"node_edits":' in router
assert "locks=list(draft.curveLock) if draft else None" in router
+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"] == []