feat(472-6 6-B 노선 바뀐 [저장] 한 본문): 노선 저장 · 관 · 구조물 다시 얹기 · 횡단 고침 넘김을 서버 한 창구로 — 전부 아니면 없음

- POST /design/save 에 route(노선 초안) · carry(옛 노선 세션 칸) 칸 · 서버 = 노선 저장 → Node facility.rebase(rebasePipeDraft · rebaseStructureDraft) → 6-A 차례(새 노선) → Node stations.carry(routeCarryPatches) → 횡단 저장
- 되돌림 범위 = 노선 행 · 자식 표 · 단계 · 배수 폴더 · 노선 파일 · 지표면 확정(설계 지형 바꿈)
- 손 상단측은 안 넘김(요구 274) · 노선 바뀐 [확정] = [저장](새 노선 본 뒤 다시)
- 브라우저 = 노선 초안 + 사용자 칸만 한 본문 · 옛 차례 길(saveRouteDraft 뒤 flush · carryAfterRouteSave · keepDraftsOnNewRoute) · 부름 0 된 flush 창구 여섯 · saveCachedCrossPatches 걷음
- 등록부 R9 stations.carry · F10 facility.rebase · 옛 차례 단언 시험 한 본문 꼴로 · test_472_6_route_save(옛 길 = 새 길 · 실패 되돌림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 12:07:12 +09:00
co-authored by Claude Opus 5.5
parent de0141b8fc
commit 4b6800e387
48 changed files with 1340 additions and 731 deletions
@@ -78,6 +78,13 @@ function loadTs(file) {
}
const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts"));
// 472-6 — B06 [저장] = 한 본문 구조물 조각(옛 기본 창구 `flushPendingStructures` 걷음) · 서버 창구 = 가짜 PUT
async function saveLikeOneBody(projectId) {
const part = await api.structuresPart(projectId);
if (!part) return;
await api.saveStructures(projectId, part.body.base_revision, part.body.structures);
await part.after?.(undefined);
}
(async () => {
const P = "p1";
@@ -87,13 +94,13 @@ const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts
const notice = api.staleDefinitionNotice(P, opened.definition_version);
const staleOnOpen = api.definitionStale(P);
// 고침 없음 [저장](B06 기본 창구) — 정본을 그대로 다시 써 판 도장
await api.flushPendingStructures(P);
await saveLikeOneBody(P);
const afterSave = { version: server.definition_version, puts: [...puts] };
// 다시 열기 — 알림 없음 · 또 [저장] 해도 안 나감
const reopened = await api.fetchStructures(P);
const again = api.staleDefinitionNotice("p1-새탭", reopened.definition_version);
api.staleDefinitionNotice(P, reopened.definition_version);
await api.flushPendingStructures(P);
await saveLikeOneBody(P);
process.stdout.write(
JSON.stringify({
notice,
+16 -22
View File
@@ -38,32 +38,18 @@ const STUBS = {
},
"../B06_Section/B06_Section_Section_Store": {
invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`),
loadSectionDetail: async (_p, routeId) => (
calls.push(`load:${routeId}`),
{ tag: routeId, cross_sections: [] }
),
saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1),
},
// 6-B — 넘김은 서버(Node · 같은 TS) · 여기서는 옛 노선 세션 칸 원본만 냄
"../B06_Section/B06_Section_Station_Keys": {
useStationKeys: (_p, routeId, detail) => calls.push(`keys:${routeId}:${detail.tag}`),
carryStationDrafts: (_p, from, to, opts) => (
calls.push(`carry:${from}->${to}${opts?.quiet ? ":quiet" : ""}`),
2
),
// 417 — 누가거리 열쇠 조절 옮김
carryChainageStates: (_p, from, to) => (calls.push(`carryAt:${from}->${to}`), 1),
routeCarryInputs: (_p, from) => (calls.push(`carryInputs:${from}`), { from_keys: [], from }),
},
"../B06_Section/B06_Section_Section_Adjusts": {
withDraftAdjusts: (_d, _p, routeId) => calls.push(`adjusts:${routeId}`),
withCarriedEdits: (_d, _p, routeId) => calls.push(`edits:${routeId}`),
"../B06_Section/B06_Section_Api_Fetch": {
sectionConfirmBody: (standard, patches, massHaul) => ({ standard, patches, massHaul }),
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => (calls.push("pipes"), { pipe_points: [] }),
},
"../A00_Common/design/design_pipe_ids": { pipeChainageOf: () => null },
"../B06_Section/B06_Section_UI_Standard_Panel": { readStandardCrossSession: () => ({ std: 1 }) },
"@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({ b: [1, 2] }) },
"./B05_Profile_Api_Fetch": {
clearRouteLatestCache: () => calls.push("latest:clear"),
flushUphillOverrides: async () => calls.push("uphill"), // 옮긴 뒤 · 횡단 패치 앞(443-2)
},
"./B05_Profile_Api_Replan": {
replanRoute: async (_p, body) => {
@@ -153,9 +139,17 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
out.keptAfterSave = kept;
out.summary = apply.routeSaveSummary(saved, 2);
// 저장 뒤 횡단 초안 넘김 차례 — 새 상세 → 측점 열쇠 → 넘김 → 넘긴 고침 저장
// 노선 바뀐 [저장] 횡단 조각(6-B) — 옛 노선 세션 칸 원본 + 표준 횡단 · 풍선 · 저장 뒤 상단측 · 종류 바꿈 비움
calls.length = 0;
out.outside = await apply.carryAfterRouteSave("p1", 7, 9);
writeState("uphill", { "40.00": "right" }, "p1");
writeState("kindreset", { "45.00": "culvert" }, "p1");
const part = apply.routeCarryPart("p1", 7);
out.carry = part.carry;
out.crossBody = part.cross.body;
out.uphillBeforeAfter = readState("uphill", "p1");
await part.cross.after(undefined);
out.uphillAfter = readState("uphill", "p1");
out.kindresetAfter = readState("kindreset", "p1");
out.carryCalls = [...calls];
// 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움
+2 -19
View File
@@ -1,5 +1,5 @@
/* PLAN 429 단위검증 헬퍼 — [저장] 길 정합.
* ④ 노선 저장 뒤 횡단 저장 = 고침이 없어도 한 번(`saveCachedCrossPatches`)
* ④ 노선 저장 뒤 횡단 저장 = 고침이 없어도 한 번(472-6 6-B — 서버 한 본문 · 시험은 글 단언)
* ⑨ 추가 측점 = 계산 묶음 입력(`plusStations` · stations 부터) · 가게 초안 바뀜 → 묶음에 넣음(`feedFacilities`)
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구 · 화면 모듈은 가짜. 짝 = `test_429_save_path.py`. */
const fs = require("fs");
@@ -39,24 +39,7 @@ function loader(stubs, real = () => false) {
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,
},
// 445 H5 — 노선 저장 뒤 본문 = 횡단 보기와 같은 칸(세션 고침 · 표준 횡단면 · 풍선)
"./B06_Section_Cross_Design_Session": { crossDesignChoices: () => new Map() },
"./B06_Section_UI_Page_Persist": {
readRockBoundarySession: () => ({}),
readCutSlopeSession: () => ({}),
},
"./logic/B06_Section_UI_Page_Patches": { mergeCrossPatches: (a, b) => [...a, ...b] },
"@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({}) },
})(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
out.noPatchReturn = await store.saveCachedCrossPatches("p1", 9);
out.noPatchSent = sent;
// ④ 노선 저장 뒤 횡단 저장 한 번 = 서버 한 본문 몫(472-6 6-B · test_429_save_path 글 단언)
// ⑨ 묶음 — 추가 측점이 바뀌면 stations 부터 · 같은 값이면 안 셈
const DESIGN = path.join(ROOT, "A00_Common", "design");
+31 -16
View File
@@ -1,6 +1,6 @@
/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료.
* ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎
* ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
* ① 노선 바꾼 [저장](472-6 6-B 한 본문)에도 settings 기본 조각이 실림(화면 몫 아님)
* ② pageSettingsPart — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
* ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets
* ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */
@@ -47,24 +47,36 @@ async function run() {
// ── ① 저장 차례 ──
const calls = [];
const KEYS = ["settings", "pipes", "structures", "plusStations", "alignment"];
const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), {
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`),
pageSettingsPart: (projectId) => ({
body: { projectId },
after: () => calls.push(`settings:${projectId}`),
}),
},
"./design_save_api": {
SAVE_PART_KEYS: [...KEYS, "cross"],
DesignSaveError: Error,
postDesignSave: async (_p, _routeId, _confirm, parts, extra = {}) => {
const sent = KEYS.filter((key) => parts[key]?.body);
calls.push(`post:${extra.route ? "route:" : ""}${sent.join(",")}`);
return { waited: false, steps: { route: { route_id: 9 } } };
},
},
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": {
pendingRouteSave: async () => ({ draft: 1 }),
saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }),
carryAfterRouteSave: async () => [],
routeCarryPart: () => ({ carry: {}, cross: { body: {} } }),
routeSaved: () => undefined,
routeSaveSummary: () => "",
rebuildAfterRouteSave: async () => undefined,
},
});
out.order = [...save.DESIGN_SAVE_ORDER];
const done = await save.saveDesign({
projectId: "p1",
routeId: () => 3,
steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀
cross: async () => calls.push("cross"),
parts: { settings: () => (calls.push("own-settings"), null) }, // 노선 바뀌면 화면 몫 건너뜀
crossPart: async () => (calls.push("cross"), null),
failed: (error) => calls.push(`failed:${error.message}`),
});
out.routeSaveDone = done;
@@ -96,16 +108,19 @@ async function run() {
clearState: (name, p) => session.delete(`${name}:${p}`),
},
"./B05_Profile_Api_Fetch": {
savePageSettings: async (projectId, body) => {
puts.push(body);
if (mode === "boom") throw new Error("서버 500");
return { warnings: [], profile_updated: true };
},
readRouteLatestCache: () => latest,
writeRouteLatestCache: (_p, value) => (latest = value),
},
});
await api.flushPageSettings("p1");
// 472-6 — 서버 한 창구가 settings 칸을 받고(가짜) 다 저장한 뒤에만 조각 정리(옛 창구 `flushPageSettings` 걷음)
const flushPageSettings = async (projectId) => {
const part = api.pageSettingsPart(projectId);
if (!part) return;
puts.push(part.body);
if (mode === "boom") throw new Error("서버 500");
await part.after?.({ warnings: [], profile_updated: true });
};
await flushPageSettings("p1");
out.putsWithoutDraft = puts.length;
const draft = api.pageSettingsOf({
designSpeed: 30,
@@ -120,13 +135,13 @@ async function run() {
api.writePageSettings("p1", draft);
out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1"));
mode = "boom";
out.boom = await api.flushPageSettings("p1").then(
out.boom = await flushPageSettings("p1").then(
() => "ok",
(error) => error.message,
);
out.draftAfterBoom = api.readPageSettings("p1");
mode = "ok";
await api.flushPageSettings("p1");
await flushPageSettings("p1");
out.draftAfterOk = api.readPageSettings("p1");
out.latestAfterOk = latest;
out.puts = puts.length;
+6 -9
View File
@@ -86,17 +86,14 @@ async function run() {
},
"@config/config_frontend": { API_BASE_URL: "", API_TIMEOUT_MS: 1000 },
});
// 472-6 — 상단측 차례 = 한 본문 `uphill` 칸(본문은 세션 · 비우기는 서버가 다 저장한 뒤 조각 정리 ·
// 하위 차례 실패 = 서버가 그 차례에서 되돌림 · 옛 창구 `flushUphillOverrides` · `saveRouteParts` 걷음)
state.set("uphill:p1", { "40.000": "right" });
reply = { failed_parts: ["상단측 병합"] };
out.flushFailed = await settle(api.flushUphillOverrides("p1"));
out.uphillAfterFail = state.get("uphill:p1") ?? null;
reply = { failed_parts: [] };
out.flushOk = await settle(api.flushUphillOverrides("p1"));
out.uphillAfterOk = state.get("uphill:p1") ?? null;
out.sentAgain = await settle(api.flushUphillOverrides("p1")); // 비었으니 안 보냄
out.exported = ["flushUphillOverrides", "saveRouteParts", "routePartsBody"].filter(
(name) => typeof api[name] === "function",
);
out.uphillAfterBody = state.get("uphill:p1") ?? null;
out.bodies = bodies;
reply = { failed_parts: ["비정규 측점 횡단"] };
out.partsFailed = await settle(api.saveRouteParts("p1", { irregular_stations: [] }));
return out;
}
+19 -13
View File
@@ -75,7 +75,7 @@ STUBS["B05_Profile_Api_Structures"] = {
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎
};
STUBS["B05_Profile_Api_PlusStations"] = {
flushPlusStations: async () => undefined,
outsideNotice: () => "",
plusStationsPart: () => ({ body: null }),
};
STUBS["B05_Profile_Api_Fetch"] = {
@@ -108,30 +108,36 @@ STUBS["design_save_api"] = {
"uphill",
"cross",
],
DesignSaveError: class extends Error {},
postDesignSave: async (_projectId, routeId, confirm, parts) => {
DesignSaveError: class extends Error {
constructor(message, step, restored) {
super(message);
this.step = step;
this.restored = restored;
}
},
postDesignSave: async (_projectId, routeId, confirm, parts, extra = {}) => {
const pipes = parts.pipes?.body;
calls.push([
"SAVE",
pipes === undefined || pipes === null ? "none" : (pipes.points ?? null),
confirm,
...(extra.route ? ["ROUTE"] : []),
]);
const align = parts.alignment?.body;
if (align) {
const { route_id: _id, ...draft } = align;
calls.push(["ALIGN", routeId, draft]);
}
return { waited: false, steps: {} };
// 472-6 6-B — 서버가 그 차례에서 실패 = 노선까지 되돌림(전부 아니면 없음)
const Err = STUBS["design_save_api"].DesignSaveError;
if (failAt) throw new Err(`${failAt} 409`, failAt, true);
return { waited: false, steps: extra.route ? { route: { route_id: 8 } } : {} };
},
};
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
carryAfterRouteSave: async (_projectId, from, to, options) => {
calls.push(["CARRY", from, to, options?.save ?? true]);
failing("carry");
return 0;
},
routeCarryPart: (_projectId, from) => ({ carry: { from }, cross: { body: {} } }),
routeSaved: () => void calls.push(["ROUTE_SAVED"]),
routeSaveSummary: () => "",
rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]),
};
@@ -180,10 +186,10 @@ async function run() {
alignment.set(0, { pvi: { 120: 1.5 } });
out.routeFail = await once({ route: true, fail: "structures" });
out.alignAfterFail = Object.fromEntries(alignment);
out.nextSave = await once({ routeId: 8 });
// 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김
out.nextSave = await once({ route: true }); // 다음 [저장] = 같은 초안 그대로 다시
// 472-6 6-B — 횡단 넘김(서버 Node) 실패도 노선까지 되돌림 · 화면은 그대로
alignment.clear();
out.carryFail = await once({ route: true, fail: "carry" });
out.carryFail = await once({ route: true, fail: "cross" });
return out;
}
@@ -0,0 +1,195 @@
/* 472-6 6-B 헬퍼 — 노선 바뀐 [저장] 횡단 넘김: 브라우저 옛 길(세션 함수를 옛 `carryAfterRouteSave` →
* `saveCachedCrossPatches` 차례 그대로) 결과 · 새 길(서버 Node `stations.carry`) 입력을 JSON 한 벌로.
* node helper_472_6_route_carry.mjs <묶음.mjs>
* 시험(`test_472_6_route_save.py`)이 새 길 입력을 진짜 Node 입구로 돌려 두 결과를 견줌. */
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const [bundlePath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const P = "p4726";
const R = 7; // 옛 노선
const R2 = 9; // 새 노선(서버가 저장한 것)
const clone = (value) => JSON.parse(JSON.stringify(value));
const pipe = { structure: "파형강관 D800", culvert: {} };
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
chainage_m,
kind,
label: String(chainage_m),
samples: [],
design: {},
...extra,
});
const detailOf = (sections) => ({
longitudinal: { stations: sections, profile_alignment: { policy: { station_interval_m: 20 } } },
cross_sections: sections,
});
/* 새 노선 서버 상세 — 50 m 뒤가 3 m 밀림(관 80 → 83) · MC 110 → 112 · 구조물 130 사라짐 · 끝 140 → 143 */
const fresh = () =>
detailOf([
section(0, "bp"),
section(20, "regular", { design: { ground_type: "rock" } }),
section(40, "regular"),
section(60, "regular", { design: { display_half_width_m: 5, ground_type: "soil" } }),
section(83, "irregular", pipe),
section(103, "regular"),
section(112, "curve"),
section(123, "regular"),
section(143, "ep"),
]);
/* 옛 노선 열쇠 목록(브라우저가 끌기 동안 세운 것) · 측점 주인(초안 자리) · 관 장부 */
m.useStationKeyList(P, R, [
{ key: "end:BP", chainageM: 0 },
{ key: "grid:1", chainageM: 20 },
{ key: "grid:2", chainageM: 40 },
{ key: "grid:3", chainageM: 60 },
{ key: "pipe:p80", chainageM: 80 },
{ key: "grid:5", chainageM: 100 },
{ key: "curve:110.00", chainageM: 110 },
{ key: "grid:6", chainageM: 120 },
{ key: "structure:s1", chainageM: 130 },
{ key: "end:EP", chainageM: 140 },
]);
m.useStationOwners(P, [{ chainage_m: 83, owners: ["p80"] }], [{ id: "p80" }]);
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 83 }] }, P);
const pipesBefore = [{ id: "p80", chainage_m: 80 }];
const pipesAfter = [
{ id: "p80", chainage_m: 83, options: { inlet_revet_height_m: 1.8, inlet_revet_form: "I" } },
];
/* 옛 노선 세션 고침 — 측점 열쇠 칸 다섯 · 누가거리 칸 · 프로젝트 칸(종류 바꿈 · 상단측) */
m.writeCrossDesignChoice(P, R, 20, {
ground_type: "soil",
section_mode: "left_cut",
ditch_type: "u_flume",
two_stage_slope: true,
});
m.writeCrossDesignChoice(P, R, 130, { ground_type: "soil", section_mode: "right_cut" }); // 밖
m.writeShoulderExtend(P, R, 40, "left", 0.5);
m.writeBermDepth(P, R, 130, "right", 1); // 밖
m.writeState("rockb", { "grid:5": 1.5, "@at": { "grid:5": 100 } }, P, R);
m.writeState("cutslope", { "curve:110.00": 0.8, "@at": { "curve:110.00": 110 } }, P, R);
const wall = { x: 0.3, d: null, h: 1.5, m: null };
m.writeState("revetx", { "80.00:outlet": wall, "40.00:inlet": wall }, P, R); // 40 = 일반 측점 → 버림
m.writeState("basinadjust", { "80.00": { depth_m: 1.2 } }, P, R);
m.writeState("crossw", { "60.00": 6 }, P, R);
m.writeState("structure-notes", { "130.00": "메모", "20.00": "앞 메모" }, P, R);
m.writeState("detail-table", { "80.00": { rows: 2 } }, P, R);
m.writeState("revetlink", { detached: { "80.00": true }, grade: {} }, P, R);
m.writeState("kindreset", { "60.00": "culvert" }, P); // 일반 측점 = 관 · 구조물 열쇠 아님 → 버림
m.writeState("uphill", { "60.00": "right" }, P);
const out = {};
/* 새 길 입력 — 브라우저가 한 본문 `carry` 칸에 싣는 것(넘기기 전 세션 그대로) */
out.carry = clone(m.routeCarryInputs(P, R));
out.detail = fresh();
out.pipesBefore = pipesBefore;
out.pipesAfter = pipesAfter;
/* 옛 길 — `loadSectionDetail`(관 옵션 얹기) → `carryAfterRouteSave` → `saveCachedCrossPatches` 차례 그대로 */
const detail = fresh();
m.layPipeOptions(detail, pipesAfter);
m.useStationKeys(P, R2, detail);
const newAt = (id) => pipesAfter.find((p) => id !== undefined && p.id === id)?.chainage_m ?? null;
const pipeAt = (key) => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? m.pipeChainageOf(id, P) : newAt(id);
};
const movedAt = (chainageM) =>
newAt(pipesBefore.find((p) => Math.abs(p.chainage_m - chainageM) < 0.01)?.id);
const outside =
m.carryStationDrafts(P, R, R2, { quiet: true }) +
m.carryChainageStates(P, R, R2, { keyAt: pipeAt, movedAt });
m.withDraftAdjusts(detail, P, R2);
m.withCarriedEdits(detail, P, R2);
const base = m.crossPatchesFromCache(
detail,
m.kindChangedChainages(P),
detail.cross_sections.map((s) => s.chainage_m),
);
const overlay = [];
m.crossDesignChoices(P, R2).forEach((choice, chainage) => {
const patch = {
chainage_m: chainage,
ground_type: choice.ground_type,
section_mode: choice.section_mode,
};
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
overlay.push(patch);
});
// 옛 `readRockBoundarySession` · `readCutSlopeSession` = 측점 열쇠 칸을 새 목록 누가거리로(`readKeyed`)
const keyed = (name) => [...m.readKeyed(name, P, R2)];
for (const [chainage, offset] of keyed("rockb"))
if (typeof offset === "number")
overlay.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
for (const [chainage, ratio] of keyed("cutslope"))
overlay.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
out.oldPatches = JSON.parse(
JSON.stringify(
m.mergeCrossPatches(
base,
overlay.filter((p) => Number.isFinite(p.chainage_m)),
),
),
);
out.oldOutside = outside;
/* 관 · 구조물 초안 다시 얹기 — 브라우저 옛 길이 부르던 같은 함수(서버 Node `facility.rebase` 와 견줌) */
out.rebaseInput = {
pipes: {
draft: [
{ id: "p80", chainage_m: 80, x: 80, y: 0 }, // 따라가기만 함 → 서버 자리
{ id: "p20", chainage_m: 25, x: 25, y: 0 }, // 사용자가 옮김 → 그대로
],
before: [
{ id: "p80", chainage_m: 80, x: 80, y: 0 },
{ id: "p20", chainage_m: 20, x: 20, y: 0 },
],
after: [
{ id: "p80", chainage_m: 83, x: 80, y: 0 },
{ id: "p20", chainage_m: 20, x: 20, y: 0 },
{ id: "pnew", chainage_m: 120, x: 117, y: 2 }, // 서버가 새로 놓은 교차 관
],
},
structures: {
draft: [{ structure_id: "s1", chainage_m: 60, start_m: null, end_m: null, x: 60, y: 0 }],
before: [{ structure_id: "s1", chainage_m: 60, start_m: null, end_m: null, x: 60, y: 0 }],
after: [{ structure_id: "s1", chainage_m: 63, start_m: null, end_m: null, x: 60, y: 0 }],
},
};
const r = out.rebaseInput;
out.oldRebase = {
pipes: m.rebasePipeDraft(r.pipes.draft, r.pipes.before, r.pipes.after),
structures: m.rebaseStructureDraft(r.structures.draft, r.structures.before, r.structures.after),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,29 @@
/* 472-6 6-B 시험 묶음 진입점 — 브라우저 옛 길(세션 함수 차례 · 옛 `carryAfterRouteSave` + `saveCachedCrossPatches`)과
* 새 길 입력(`routeCarryInputs`)을 한 벌로 묶어 Node 헬퍼(helper_472_6_route_carry.mjs)가 부름. */
export {
carryChainageStates,
carryStationDrafts,
readKeyed,
routeCarryInputs,
useStationKeyList,
useStationKeys,
useStationOwners,
} from "../../B06_Section/B06_Section_Station_Keys";
export {
crossDesignChoices,
writeBermDepth,
writeCrossDesignChoice,
writeShoulderExtend,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export {
layPipeOptions,
withCarriedEdits,
withDraftAdjusts,
} from "../../B06_Section/B06_Section_Section_Adjusts";
export { crossPatchesFromCache } from "../../B06_Section/B06_Section_Route_Carry_Patch";
export { mergeCrossPatches } from "../../B06_Section/logic/B06_Section_UI_Page_Patches";
export { kindChangedChainages } from "../../B06_Section/B06_Section_Api_Culvert_Options";
export { pipeChainageOf } from "../../A00_Common/design/design_pipe_ids";
export { readState, writeState } from "../../A00_Common/b_page_state";
export { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow";
export { chainageKey } from "../../common_util/common_util_station_key";
+24 -33
View File
@@ -33,14 +33,14 @@ let failStructures = false;
// 노선 저장(346-3) — 초안 · 실패를 시험이 정함
let routeBody = null;
let failRoute = false;
// 6-B — 노선 저장 · 다시 얹기 · 넘김은 서버 한 본문 · 여기서는 옛 노선 세션 칸 원본 조각만
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),
routeCarryPart: (_p, from) => ({
carry: { from },
cross: { body: { carried: from }, after: () => calls.push("cross:carried:after") },
}),
routeSaved: () => calls.push("routeSaved"),
routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""),
rebuildAfterRouteSave: async () => calls.push("rebuild"),
};
@@ -64,11 +64,23 @@ const SAVE_PART_KEYS = [
const saveApiStub = {
SAVE_PART_KEYS,
DesignSaveError,
postDesignSave: async (_p, routeId, confirm, parts) => {
postDesignSave: async (_p, routeId, confirm, parts, extra = {}) => {
const sent = SAVE_PART_KEYS.filter((key) => parts[key] && parts[key].body != null);
calls.push(`post:${routeId}:${confirm}:${sent.join(",")}`);
const moved = extra.route ? `:route(carry ${extra.carry?.from})` : "";
calls.push(`post:${routeId}:${confirm}:${sent.join(",")}${moved}`);
if (extra.route && parts.alignment?.body) {
const body = parts.alignment.body; // 서버가 새 노선 번호로 고쳐 씀(test_472_6_route_save)
calls.push(`alignment:${body.route_id}:${JSON.stringify(body.station_offsets)}`);
}
if (failStructures) throw new DesignSaveError("structures 실패", "structures", true);
return { waited: false, steps: { alignment: { dropped_edit_chainages: [] } } };
if (extra.route && failRoute) throw new DesignSaveError("노선 저장 실패", "route", true);
const route = extra.route
? {
route: { route_id: 9, pipes: { kept: 3, crossing: 1, concave: 0 }, cross_dropped: 1 },
carry: { outside: 2 },
}
: {};
return { waited: false, steps: { alignment: { dropped_edit_chainages: [] }, ...route } };
},
};
const STUBS = {
@@ -79,41 +91,22 @@ const STUBS = {
hideLoadingOverlay: () => calls.push("overlay:off"),
},
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
flushPendingPipes: () => record("pipes")(),
pipesPart: () => ({ body: { points: [] }, after: () => calls.push("pipes:after") }),
readPendingPipes: () => null, // 관 초안 없음 — 노선 저장 뒤 얹기(349-0u)는 안 돎
writePendingPipes: () => undefined,
},
"../../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => ({ pipe_points: [] }),
},
"../../common_util/common_util_route_follow": {
rebasePipeDraft: (draft) => draft,
rebaseStructureDraft: (draft) => draft,
},
"../../B05_Profile/B05_Profile_Api_Structures": {
flushPendingStructures: () => record("structures", failStructures)(),
structuresPart: async () => ({
body: { base_revision: 0, structures: [] },
after: () => calls.push("structures:after"),
}),
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎
writePendingStructures: () => undefined,
fetchStructures: async () => ({ structures: [] }),
},
"../../B05_Profile/B05_Profile_Api_PlusStations": {
flushPlusStations: () => record("plusStations")(),
outsideNotice: (count) => `노선 밖 ${count}`,
plusStationsPart: () => ({ body: null, after: () => calls.push("plusStations:after") }),
},
"../../B05_Profile/B05_Profile_Api_Fetch": {
flushUphillOverrides: () => record("uphill")(),
flushRouteParts: () => record("uphill")(), // 상단측 차례 = 종단 보기 몫과 같은 본문(463)
routePartsBody: async () => ({ irregular_stations: [] }),
saveProfileAlignment: async (_p, routeId, edits) =>
calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`),
routePartsBody: async () => ({ irregular_stations: [] }), // 상단측 차례 = 종단 보기 몫과 같은 본문(463)
},
"../../B06_Section/B06_Section_Api_Culvert_Options": {
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
culvertOptionsPart: (key) => ({
body: { "10.00": {} },
after: () => calls.push(`culvert:after:${key}`),
@@ -121,7 +114,6 @@ const STUBS = {
},
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async () => undefined,
pageSettingsPart: () => null,
},
// 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림
@@ -149,7 +141,7 @@ function loadTs(file) {
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
const { saveDesign, DESIGN_SAVE_ORDER } = loadTs(
const { saveDesign } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
);
@@ -222,7 +214,6 @@ async function run() {
out.saveOk = ok;
out.saveCalls = [...calls];
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
out.order = DESIGN_SAVE_ORDER;
// ④ 구조물이 실패하면 그 차례에서 멈춤 · 차례 이름째 알림(429 ⑨) · 노선 없으면 안 나감
calls.length = 0;
+3 -2
View File
@@ -80,8 +80,9 @@ def test_서버는_그_본문이면_비정규_측점_횡단을_맞춤(got):
def test_종단_보기_몫은_기본_창구_함수를_부름():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8")
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
# 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장은 옛 창구
# 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장도 한 본문(6-B ·
# 옛 창구 `flushRouteParts` 걷음 · 노선 바뀌면 손 상단측 안 보냄 = 요구 274)
assert "uphill: (base) =>" in actions and "body: base.body," in actions
assert "saveRouteParts(" not in actions and "flushRouteParts" not in actions
assert "body: await routePartsBody(projectId)," in save
assert "return () => flushRouteParts(projectId);" in save
assert "flushRouteParts" not in save and "if (moved) return null;" in save
@@ -75,19 +75,17 @@ def test_저장_뒤_가게_초안_비움_결과_한_줄(out):
def test_초안_넘김_차례(out):
assert out["carryCalls"] == [
"invalidate:9",
"load:9",
"keys:9:9",
"pipes", # 관 열쇠 → 새 자리(서버가 옮긴 관)
"carry:7->9:quiet",
"carryAt:7->9", # 417 — 조절 · 측점 폭 · 상세 표도 새 노선 측점으로
"uphill", # 443-2 — 상단측도 새 측점으로 옮긴 뒤 · 횡단 패치 앞
"adjusts:9",
"edits:9", # 옮긴 고침을 새 상세 설계 칸에 → 같은 [저장] 횡단 패치
"crossSave:9",
]
assert out["outside"] == 3 # 측점 열쇠 2 + 누가거리 열쇠 1
"""472-6 6-B — 넘김(측점 열쇠 · 누가거리 칸 · 얹기 · patch)은 서버가 같은 TS 로(Node `stations.carry` ·
`test_472_6_route_save`) · 브라우저는 옛 노선 세션 칸 원본 + 횡단 본문만 · 저장 뒤 상단측 · 종류 바꿈 비움."""
assert out["carryCalls"][0] == "carryInputs:7"
assert out["carry"] == {"from_keys": [], "from": 7}
assert out["crossBody"] == {
"standard": {"std": 1},
"massHaul": {"balloon_offsets": {"b": [1, 2]}},
}
assert out["uphillBeforeAfter"] == {"40.00": "right"} # 다 저장하기 전엔 그대로
assert out["uphillAfter"] is None and out["kindresetAfter"] is None
assert any("상단측 손 고침 1곳" in c for c in out["carryCalls"]) # 요구 274 — 새 지형이 정함
def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
@@ -95,8 +93,8 @@ def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
# 잠금 막 하나 · 저장 직전 저장 관 떠 둠(417 — 저장 뒤 같은 관 id 새 자리로 조절 옮김)
assert out["recommendCalls"] == ["overlay:on", "pipes", "replan", "overlay:off"]
# 잠금 막 하나(472-6 6-B — 저장 직전 저장 관 떠 두기는 서버 한 본문 몫 · 추천은 초안을 비우니 안 받음)
assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"]
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
@@ -114,9 +112,12 @@ def test_노선_반영_단추_없앰_배선():
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
# 6-B — 노선 초안 = 한 본문 `route` 칸 · 서버가 맨 앞에서 노선 저장(옛 차례 길 걷음)
assert "route ? { route, carry: carried?.carry } : {}" in save
assert "carryAfterRouteSave" not in save and "saveRouteDraft" not in save
server = _read("B06_Section/B06_Section_Router_DesignSave.py")
assert server.index('step("route"') < server.index('step("settings"')
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
at = initial.index("onApply: (picked) => {")
assert "saveRecommendedRoute(" in initial[at : at + 900]
@@ -129,8 +130,11 @@ def test_넘김_알림은_결과_한_줄에_합침():
keys = _read("B06_Section/B06_Section_Station_Keys.ts")
assert "{ quiet = false }: { quiet?: boolean } = {}," in keys
assert "if (outside.size && !quiet)" in keys
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert "carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true })" in apply
# 6-B — 서버 넘김이 버린 수(`outside` · 측점 열쇠 + 누가거리 칸)를 결과 한 줄에(`routeSaveSummary`)
patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts")
assert "outside: keyed.outside.size + chain.outside.size" in patch
save = _read("A00_Common/design/design_save.ts")
assert "routeSaveSummary(saved, carryOutside)" in save
def test_노선_반영_글자_없음():
+6 -2
View File
@@ -34,9 +34,13 @@ def test_노선이_바뀌면_좌표로_따라감(tmp_path: Path) -> None:
def test_저장_차례_계획고_앞() -> None:
save = (ROOT / "A00_Common/design/design_save.ts").read_text(encoding="utf-8")
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
# 472-6 — 차례 = 한 본문 칸 차례(브라우저) · 서버 한 창구 차례
api = (ROOT / "A00_Common/design/design_save_api.ts").read_text(encoding="utf-8")
order = api[api.index("SAVE_PART_KEYS = [") :]
assert order.index('"structures"') < order.index('"plusStations"') < order.index('"alignment"')
server = (ROOT / "B06_Section/B06_Section_Router_DesignSave.py").read_text(encoding="utf-8")
assert server.index('step("structures"') < server.index('step("plusStations"')
assert server.index('step("plusStations"') < server.index('step("alignment"')
def test_옛_측점_옮기기는_추가_측점을_구조물로_안_만듦() -> None:
# 저장된 추가 측점 = 이름 "" 비정규 측점 — 옛 자유 글 옮기기가 「기타」 구조물로 바꾸면 안 됨
+11 -3
View File
@@ -48,9 +48,17 @@ def _ground() -> list[dict]:
def test_노선_저장_뒤_고침이_없어도_횡단_저장_한_번(out):
# 본문 없이 = 서버가 다시 셈만 · 풍선 자리는 늘 실음(445 H5 — 횡단 보기 [저장]과 같은 본문)
assert out["noPatchSent"] == [["p1", 9, None, None, {"balloon_offsets": {}}]]
assert out["noPatchReturn"] == 0
"""472-6 6-B — 노선 바뀐 [저장]은 서버가 횡단 차례를 늘 한 번(넘긴 patch 가 없어도 · 뒤에 나간 계획고로
다시 셈) · 브라우저 횡단 조각 = 풍선 자리 늘 실음(445 H5 — 횡단 보기 [저장]과 같은 본문)."""
server = (ROOT / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text(encoding="utf-8")
moved = server[server.index("carry_cross(project_id, route_id") :]
moved = moved[: moved.index(" elif request.cross is not None:")]
assert 'body["cross_patches"] = carried.get("patches") or None' in moved
assert "crossing = save_sections(project_id, route_id" in moved
assert 'cross = await step("cross", crossing)' in server.replace("\n", " ").replace(" ", "")
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
part = apply[apply.index("export function routeCarryPart") :]
assert "balloon_offsets: balloonOffsetsPayload()," in part
def test_서버_저장이_횡단_높이를_저장_종단에서(tmp_path):
+10 -3
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio
import copy
import json
import re
import subprocess
from pathlib import Path
from types import SimpleNamespace
@@ -338,13 +339,19 @@ def test_E3_뜰_params_없으면_안_씀():
def test_저장_차례_settings_는_노선_뒤_관_앞(out):
order = out["order"]
assert order.index("route") + 1 == order.index("settings") < order.index("alignment")
# 472-6 — 차례 = 서버 한 창구(노선 맨 앞 · 6-B)
server = (HERE.parents[1] / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text(
"utf-8"
)
names = ("route", "settings", "pipes", "alignment")
at = [re.search(r'step\(\s*"' + name + '"', server).start() for name in names]
assert at == sorted(at)
def test_노선_바꾼_저장에도_settings_기본_창구(out):
assert out["routeSaveDone"] is True
assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구
# 한 본문에 노선 · settings 기본 조각 · 화면 몫(own) · 화면 횡단 조각 아님 · 다 저장한 뒤 정리
assert out["routeSaveCalls"] == ["post:route:settings", "settings:p1"]
def test_초안_내보내기(out):
+12 -7
View File
@@ -2,7 +2,7 @@
- 옛 누가거리 열쇠 상단측 = 측점 열쇠(옮김 목록 `ROUTE_CARRY_STATES` · 415 · 417 규칙)로 새 노선 측점에.
- 새 노선에 같은 측점이 없으면 뺌 · 수를 돌려줌(「노선 밖 고침」 · 상단측 알림).
- 저장 한 줄기: 노선을 저장했으면 상단측 차례는 건너뛰고 `carryAfterRouteSave` 가 옮긴 뒤 · 횡단 패치 앞에 보냄.
- 저장 한 줄기(472-6 6-B · 요구 274): 노선이 바뀐 [저장]은 손 상단측을 안 보냄 · 새 지형이 정함(옛 443-2 갈음).
Node 헬퍼 `helper_443_2_uphill_carry.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_443_2_entry.ts`)을 부름.
./venv/Scripts/python.exe -m pytest resources/tester/test_443_2_uphill_carry.py -q
@@ -67,11 +67,16 @@ def test_세션_없으면_그대로(out):
def test_노선_저장이면_상단측_차례는_옮긴_뒤():
"""요구 274(사용자 확정 · 472-6 6-B) — 노선이 바뀐 [저장]은 손 상단측을 안 들고 감 · 새 지형이 정함.
옛 443-2(옮긴 뒤 보냄)를 갈음 — 상단측 차례 안 보냄 · 서버도 노선 바뀜이면 그 칸을 안 받음 · 저장 뒤 세션
상단측 비우고 수만 알림. 같은 노선 안의 측점 열쇠 옮김(아래 헬퍼 시험)은 그대로."""
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
assert 'if (step === "uphill" && routeSaved) continue;' in save
assert "if (moved) return null;" in save # 상단측 조각 없음
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text("utf-8")
body = apply.split("export async function carryAfterRouteSave(")[1].split("\n}\n")[0]
carry = body.index("carryChainageStates(")
flush = body.index("await flushUphillOverrides(projectId)")
assert carry < flush < body.index("saveCachedCrossPatches(") # 옮긴 뒤 · 횡단 패치 앞
assert "상단측 ${uphillDropped}곳" in body # 못 맞춤 알림
body = apply.split("export function routeCarryPart(")[1].split("\n}\n")[0]
assert 'clearState("uphill", projectId);' in body and "상단측 손 고침 ${uphill}곳" in body
keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text("utf-8")
inputs = keys.split("export function routeCarryInputs(")[1].split("\n}\n")[0]
assert 'state === "uphill"' in inputs # 넘김 입력에서 뺌
server = (ROOT / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text("utf-8")
assert "if request.uphill is not None and not moved:" in server
+14 -5
View File
@@ -60,14 +60,23 @@ def test_구조물_차례가_토스트로_삼키지_않음():
def test_상단측_실패면_세션_그대로_성공이면_비움(out):
assert out["flushFailed"] == "상단측 병합 실패"
assert out["uphillAfterFail"] == {"40.000": "right"}
assert out["flushOk"] == "ok" and out["uphillAfterOk"] is None
assert len(out["bodies"]) == 3 # 실패 · 성공 · (빈 세션은 안 보냄) · 확정 하위 하나
"""472-6 — 상단측 = 한 본문 `uphill` 칸 · 본문 = 세션 · 세션 비움 = 서버가 다 저장한 뒤 조각 정리만
(실패면 정리가 안 돎 = 세션 그대로 · 옛 창구 `flushUphillOverrides` 걷음)."""
assert out["exported"] == ["routePartsBody"] # 옛 창구 둘 걷음 · 본문 한 벌만
assert out["uphillAfterBody"] == {"40.000": "right"} and out["bodies"] == []
fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8")
assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch # 본문 = 세션
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
part = save[save.index('case "uphill":') :]
assert 'after: () => clearState("uphill", projectId),' in part[:500]
assert "for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(" in save # 성공 뒤에만
def test_확정_하위_실패면_던짐(out):
assert out["partsFailed"] == "비정규 측점 횡단 실패"
"""472-6 — 하위 차례(비정규 측점 횡단 · 상단측 병합) 실패 = 서버 한 본문이 상단측 차례에서 실패 · 되돌림."""
server = (ROOT / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text("utf-8")
assert 'if parts.get("failed_parts"):' in server
assert 'raise StepFailed("uphill", " · ".join(parts["failed_parts"]) + " 실패")' in server
def test_종단_보기_상단측_차례도_같은_창구():
+13 -8
View File
@@ -231,17 +231,22 @@ def test_M5_새_행은_버린_수에_안_셈():
def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김():
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
body = store[store.index("export async function saveCachedCrossPatches") :]
# 472-6 6-B — 노선 저장 뒤 본문 = 서버 Node 넘김(`routeCarryPatches` · 옛 `saveCachedCrossPatches` ·
# `sessionOverlay` 몸통 그대로) · 표준 횡단면 · 풍선 = 브라우저 횡단 조각(`routeCarryPart`)
patch = (ROOT / "B06_Section" / "B06_Section_Route_Carry_Patch.ts").read_text(encoding="utf-8")
body = patch[patch.index("export function routeCarryPatches") :]
for needle in (
"sessionOverlay(projectId, routeId)",
"readStandardCrossSession(projectId)",
"balloon_offsets: balloonOffsetsPayload()",
"crossPatchesFromCache(detail, kindChanged, savedAt)",
'read<CardChoice>("crossdesign")',
'read<unknown>("rockb")',
'read<number>("cutslope")',
"mergeCrossPatches(",
):
assert needle in body, needle
overlay = store[store.index("function sessionOverlay") :]
for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("):
assert needle in overlay, needle
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
part = apply[apply.index("export function routeCarryPart") :]
for needle in ("readStandardCrossSession(projectId)", "balloon_offsets: balloonOffsetsPayload()"):
assert needle in part, needle
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert (
"cachedCrossPatches(projectId, routeId, edits.crossPatches)" in persist
+13 -9
View File
@@ -2,8 +2,8 @@
· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄
· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움
· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 →
다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김
· 노선 바뀐 [저장] 중 구조물 · 횡단 넘김 차례 실패(472-6 6-B) → 서버가 노선까지 되돌림 · 초안 그대로(0 칸
계획고 그대로 · 다시 안 세움) → 다음 [저장]이 같은 초안을 그대로 다시 보냄(옛 433-12 「새 노선 칸으로 옮김」 갈음)
· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed`
짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜).
"""
@@ -69,19 +69,23 @@ def test_빈_초안은_다_지움(out):
def test_노선_저장_뒤_실패_초안_새_노선(out):
"""472-6 6-B — 한 본문 · 서버가 구조물 차례에서 실패하면 노선까지 되돌림 → 초안 그대로 · 다시 안 세움."""
case = out["routeFail"]
assert not case["ok"] and "구조물" in case["failure"]
assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]]
assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸
assert not case["ok"] and "구조물 차례에서 실패" in case["failure"]
assert case["calls"] == [["SAVE", "none", False, "ROUTE"], ["ALIGN", 7, {"pvi": {"120": 1.5}}]]
assert out["alignAfterFail"] == {"0": {"pvi": {"120": 1.5}}} # 노선 초안 칸 그대로
align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"]
assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]]
assert align == [["ALIGN", 7, {"pvi": {"120": 1.5}}]] # 다음 [저장]이 그대로 다시
assert (
out["nextSave"]["calls"][-1] == ["REBUILD"] and ["ROUTE_SAVED"] in out["nextSave"]["calls"]
)
def test_넘김_실패면_두_번_안_넘김(out):
"""472-6 6-B — 넘김은 서버 한 본문 안(Node) · 실패 = 노선까지 되돌림 · 화면 넘김 · 다시 세움 없음."""
case = out["carryFail"]
assert not case["ok"]
assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]]
assert case["calls"][-1] == ["REBUILD"]
assert not case["ok"] and "횡단 차례에서 실패" in case["failure"]
assert case["calls"] == [["SAVE", "none", False, "ROUTE"]]
def test_공용_저장이_판_메모리를_안_보냄():
+8 -3
View File
@@ -4,7 +4,7 @@
Node 입구 = `A00_Common/design/logic/design_calc_node.ts`(`build:design-calc` · id → 함수 표).
못박는 것
- id · # 하나씩 · 1.1 표 # 전부(R1 – N1 · 43).
- id · # 하나씩 · 1.1 표 # 전부(R1 – N1 · 43) + R9 · F10(472-6 6-B).
- id 마다 실행기 있음 — `node:` 는 입구 표에 · `py:` · `browser:` · ts · 짝 이름은 그 파일에 ·
거울 시험 파일 있음.
- 묶음 `STAGE_DEPS`(`design_pipeline.ts`) = 등록부 입력에서 만든 기댐(같은지만 · 묶음 안 고침).
@@ -46,6 +46,8 @@ NODE_IDS = {
"facility.follow",
"cross.design",
"cross.berm_spans",
"facility.rebase",
"stations.carry",
}
pytestmark = pytest.mark.skipif(
@@ -101,6 +103,8 @@ GEOMETRY_PULLS = (
"B06_Section/B06_Section_UI_Cross_Revetment.ts",
"B06_Section/B06_Section_UI_Cross_Wall.ts",
"B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts",
# 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀
"B06_Section/B06_Section_Route_Carry_Patch.ts",
)
KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS)
@@ -190,8 +194,9 @@ def test_id_하나씩_표_전부(loaded) -> None:
nos = [row["no"] for row in rows]
assert len(ids) == len(set(ids))
assert len(nos) == len(set(nos))
want = {f"{k}{i}" for k, n in (("R", 8), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)}
want |= {f"F{i}" for i in range(1, 10)} | {"E1", "E2"} | {f"D{i}" for i in range(1, 5)} | {"N1"}
want = {f"{k}{i}" for k, n in (("R", 9), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)}
# R9 · F10 = 노선 바뀐 [저장] 다시 얹기(472-6 6-B)
want |= {f"F{i}" for i in range(1, 11)} | {"E1", "E2"} | {f"D{i}" for i in range(1, 5)} | {"N1"}
assert set(nos) == want
+380
View File
@@ -0,0 +1,380 @@
"""PLAN 472-6 6-B — 노선 바뀐 [저장]도 한 본문 · 다시 얹기 서버 Node(같은 TS).
① 넘김 — 브라우저 옛 길(세션 함수를 옛 `carryAfterRouteSave` → `saveCachedCrossPatches`
차례로) 결과 == 서버 Node `stations.carry`(`routeCarryPatches`) 결과(patch · 버린 수) ·
손 상단측 안 넘김(요구 274).
② 다시 얹기 — 관 · 구조물 초안(`rebasePipeDraft` · `rebaseStructureDraft`) ==
Node `facility.rebase`.
③ 옛 코드 실패 — 한 본문이 `route` · `carry` 칸을 받음 · 브라우저가 노선 저장 창구를 안 부름.
④ 실제 창구(검증_sub8 · DB · storage) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) = 노선 행 ·
자식 표 · storage 그대로 · 정상 한 본문 = 옛 차례(노선 저장 → 다시 얹기 → 횡단 저장)와
행 · 파일 같음.
검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ④ 건너뜀.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_route_save.py -q
"""
from __future__ import annotations
import asyncio
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from uuid import UUID
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT))
from common_util import common_util_save_guard as guard # noqa: E402
from common_util.common_util_calc_runner import run_calc # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
def _node(*args: str, timeout: int = 300) -> str:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=timeout
)
assert proc.returncode == 0, proc.stderr
return proc.stdout
@pytest.fixture(scope="module")
def out(tmp_path_factory) -> dict:
"""vite ssr 로 진짜 모듈을 묶어 옛 길을 돌림(417 꼴)."""
work = tmp_path_factory.mktemp("r6b")
_node(
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_472_6_route_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
)
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_472_6_route_entry.js", bundle)
return json.loads(_node(str(HERE / "helper_472_6_route_carry.mjs"), str(bundle)))
@pytest.fixture(scope="module")
def carried(out) -> dict:
inputs = {
**out["carry"],
"detail": out["detail"],
"pipes_before": out["pipesBefore"],
"pipes_after": out["pipesAfter"],
}
result = run_calc(["stations.carry"], {"carry": inputs})
assert result is not None
return result["stations.carry"]
def _by_chainage(patches: list[dict]) -> dict[str, dict]:
return {f"{patch['chainage_m']:.2f}": patch for patch in patches}
def test_넘김_옛_브라우저_길과_서버_Node_같음(out, carried) -> None:
assert carried["patches"] == out["oldPatches"]
assert carried["outside"] == out["oldOutside"]
def test_넘김_값_사용자_칸(out, carried) -> None:
"""같은 값이 뜻있는지 — 카드 선택 · 사면 쉬프트 · 암선 · 암 경사(MC 옮김) · 관 조절 · 메모가
새 측점에."""
by = _by_chainage(carried["patches"])
assert by["20.00"]["ground_type"] == "soil" and by["20.00"]["two_stage_slope"] is True
assert by["20.00"]["ditch_type"] == "u_flume"
assert "section_mode" not in by["20.00"] # 단면 유형 = 새 지형이 정함
assert by["20.00"]["structure_note"] == "앞 메모"
assert by["40.00"]["shoulder_extend_left_m"] == 0.5
assert "revet_adjust" not in by["40.00"] # 일반 측점 옹벽 조절 = 안 넘김(관 · 구조물 열쇠만)
assert by["60.00"]["display_half_width_m"] == 6 # 세션 폭이 저장분(5) 위에
assert by["103.00"]["rock_boundary_offset_m"] == 1.5
assert by["112.00"]["cut_slope_ratio_user"] == 0.8 # MC 110 → 112(곡선 열쇠 옮김)
pipe = by["83.00"]
assert pipe["revet_adjust"]["outlet"]["x"] == 0.3 # 관 따라 80 → 83
assert pipe["revet_adjust"]["inlet"]["h"] == 1.8 # 관 옵션 벽 높이(저장 관 · 새 자리)
assert pipe["basin_adjust"]["depth_m"] == 1.2 and pipe["detail_table"] == {"rows": 2}
assert pipe["revet_link_detached"] is True
assert not any(
p.get("structure_reset") for p in carried["patches"]
) # 일반 측점 종류 바꿈 = 버림
assert carried["outside"] == out["oldOutside"] >= 4 # 구조물 130 고침 셋 · 40 옹벽 · 60 종류
def test_손_상단측은_안_넘김_요구274(out) -> None:
assert "uphill" not in out["carry"]["states"]
assert out["carry"]["states"]["kindreset"] == {"60.00": "culvert"}
def test_다시_얹기_옛_함수와_서버_Node_같음(out) -> None:
result = run_calc(["facility.rebase"], {"rebase": out["rebaseInput"]})
assert result is not None
assert result["facility.rebase"] == out["oldRebase"]
pipes = {pipe["id"]: pipe["chainage_m"] for pipe in result["facility.rebase"]["pipes"]}
assert pipes == {"p80": 83, "p20": 25, "pnew": 120} # 따라감 · 사용자 옮김 그대로 · 새 교차 관
assert result["facility.rebase"]["structures"][0]["chainage_m"] == 63
def test_옛_코드_실패_한_본문이_노선을_받음() -> None:
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest
request = DesignSaveRequest.model_validate(
{
"route_id": 7,
"route": {"draft": {"nodes": [[0, 0], [10, 0]]}},
"carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None},
}
)
assert request.route is not None and request.carry is not None
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
assert "saveRouteDraft" not in save and "carryAfterRouteSave" not in save
assert re.search(r"postDesignSave\([^;]*route \? \{ route, carry", save, re.S)
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "export async function carryAfterRouteSave" not in apply
# ── 실제 창구 ──────────────────────────────────────────────────────────────
PROJECT = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" # 검증_sub8(OWNERS.md)
VOLATILE = ("created_at", "updated_at", "confirmed_at", "started_at", "completed_at")
def _db_ready() -> Path | None:
try:
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import close_db_pool, init_db_pool
async def find():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await get_project_storage_relative_path(connection, UUID(PROJECT))
finally:
await close_db_pool()
root = Path(resolve_stored_project_path(asyncio.run(find())))
return root if (root / "B05_Profile" / "route").is_dir() else None
except Exception:
return None
ROOT_DIR = _db_ready()
live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음")
def _rows() -> dict:
from config.config_db import close_db_pool, init_db_pool
async def run():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await guard.snapshot_rows(connection, PROJECT, surface=True)
finally:
await close_db_pool()
return asyncio.run(run())
def _state(root: Path) -> tuple[dict, dict]:
"""(행 — 시각 칸 뺌, 파일 내용 해시) — 노선 바뀐 저장 범위 전부."""
import hashlib
clean = {
table: [{k: str(v) for k, v in row.items() if k not in VOLATILE} for row in values]
for table, values in _rows().items()
}
files = {
relative: hashlib.sha1(path.read_bytes()).hexdigest()
for relative, path in guard._files(root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE).items()
if not relative.startswith("_derived/")
}
return clean, files
def _plan_draft() -> dict:
"""지금 계획노선 노드 · 곡선 그대로 + 가운데 노드 하나를 3 m 옆으로(노선 끌기)."""
from B05_Profile.B05_Profile_Router_Replan import read_route_plan
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
return await read_route_plan(UUID(PROJECT))
finally:
await close_db_pool()
plan = asyncio.run(run())
nodes = plan["nodes"]
at = len(nodes) // 2
points = [[float(n["x"]), float(n["y"])] for n in nodes]
points[at][1] += 3.0
return {
"rev": 1,
"nodes": points,
"curveOn": [True] * len(nodes),
"curveRadius": [None] * len(nodes),
"stationSnap": bool(plan.get("station_snap")),
"stationSnapStep": plan.get("station_snap_step"),
}
def _save(body: dict):
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest, save_design
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
return await save_design(UUID(PROJECT), DesignSaveRequest.model_validate(body), None)
finally:
await close_db_pool()
response = asyncio.run(run())
return response.status_code, json.loads(bytes(response.body).decode("utf-8"))
def _latest_route_id() -> int:
return max(int(row["id"]) for row in _rows()["routes"])
def _body(draft: dict, **extra) -> dict:
return {
"route_id": _latest_route_id(),
"route": {"draft": draft},
"carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None},
"cross": {},
**extra,
}
@live
def test_노선_차례_뒤_실패면_노선_행_storage_그대로(monkeypatch) -> None:
before = _state(ROOT_DIR)
monkeypatch.setenv("AISLO_DESIGN_SAVE_FAIL_AFTER", "route")
status, body = _save(_body(_plan_draft()))
assert status == 409 and body["step"] == "route" and body["restored"] is True, body
assert _state(ROOT_DIR) == before # 새 노선 행 · 자식 표 · 노선 파일 · 관 · 구조물 그대로
@live
def test_넘김_실패면_노선까지_그대로(monkeypatch) -> None:
from B06_Section import B06_Section_Router_DesignSave_Route as moving
before = _state(ROOT_DIR)
def broken(ids, inputs):
if ids == ["stations.carry"]:
raise RuntimeError("넘김 일부러 실패")
return run_calc(ids, inputs)
monkeypatch.setattr(moving, "run_calc", broken)
status, body = _save(_body(_plan_draft()))
assert status == 409 and body["step"] == "cross" and body["restored"] is True, body
assert "넘김 일부러 실패" in body["message"]
assert _state(ROOT_DIR) == before
def _normalized(state: tuple[dict, dict], new_ids: set[int]) -> tuple[dict, dict]:
"""새 노선 번호 · 행 id · 계산한 때(`computed_at`)를 지운 비교 꼴 — 옛 길 · 새 길이 다른
자동 번호 · 시각을 받음."""
rows, files = state
numbers = "|".join(str(i) for i in sorted(new_ids, reverse=True)) or "x"
# 3D 파일 = 0 채운 번호(`corridor_0444.json`)
rename = re.compile(rf"(?<!\d)0*({numbers})(?!\d)")
keep = {}
for table, values in rows.items():
out = []
for row in values:
row = {k: v for k, v in row.items() if k not in ("id", "route_id", "computed_at")}
out.append(rename.sub("N", json.dumps(row, sort_keys=True, ensure_ascii=False)))
keep[table] = sorted(out)
return keep, {rename.sub("N", k): v for k, v in files.items()}
@live
def test_정상_한_본문은_옛_차례와_같음() -> None:
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Router_Replan import replan_route
from B06_Section import B06_Section_Router_DesignSave_Route as moving
from B06_Section.B06_Section_Router_Confirm import save_sections
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from config.config_db import close_db_pool, get_db_pool, init_db_pool
start = _state(ROOT_DIR)
draft = _plan_draft()
old_ids = {int(row["id"]) for row in _rows()["routes"]}
async def old_way():
"""옛 브라우저 차례 — 노선 저장(`/route/replan`) → 횡단 넘김 patch → 횡단 저장."""
await init_db_pool()
try:
async with get_db_pool().acquire() as connection:
rows = await guard.snapshot_rows(connection, PROJECT, surface=True)
files = guard.snapshot_storage(ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE)
before = await moving.saved_facilities(UUID(PROJECT))
saved = await replan_route(
UUID(PROJECT), RouteSaveRequest.model_validate({"draft": draft})
)
carried = await moving.carry_cross(UUID(PROJECT), int(saved["route_id"]), None, before)
await save_sections(
UUID(PROJECT),
int(saved["route_id"]),
SectionConfirmRequest.model_validate({"cross_patches": carried["patches"] or None}),
)
return rows, files
finally:
await close_db_pool()
rows, files = asyncio.run(old_way())
old = _state(ROOT_DIR)
async def undo():
await init_db_pool()
try:
files.restore()
async with get_db_pool().acquire() as connection:
await guard.restore_rows(connection, PROJECT, rows)
finally:
await close_db_pool()
asyncio.run(undo())
assert _state(ROOT_DIR) == start
try:
status, body = _save(_body(draft))
assert status == 200 and body["status"] == "success", body
assert set(body["steps"]) == {"route", "carry", "cross"}
new = _state(ROOT_DIR)
ids = {int(row["id"]) for row in _rows()["routes"]} | {
int(row["id"]) for row in json.loads(json.dumps(old[0]["routes"]))
}
ids -= old_ids
(rows_new, files_new), (rows_old, files_old) = _normalized(new, ids), _normalized(old, ids)
assert [t for t in rows_new if rows_new[t] != rows_old.get(t)] == []
assert (
sorted(
k
for k in files_new.keys() | files_old.keys()
if files_new.get(k) != files_old.get(k)
)
== []
)
finally:
asyncio.run(undo())
files.discard()
assert _state(ROOT_DIR) == start
+15 -20
View File
@@ -15,32 +15,28 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
#: (파일, 함수 이름, 비었을 때 돌아가는 표시)
#: (파일, 함수 이름, 비었을 때 돌아가는 표시) — 472-6 한 본문 조각(빈 초안 = null 조각 = 서버가 그 차례를
#: 건너뜀 · 옛 창구 함수는 6-B 에서 걷음). 상단측은 463 부터 늘 나감(비정규 측점 횡단 맞춤 · 노선 바뀜이면 안 나감).
CHANNELS = [
(
ROOT / "B05_Profile" / "B05_Profile_Api_Pipes_Draft.ts",
"flushPendingPipes",
"pipesPart",
"관 목록",
),
(
ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts",
"flushUphillOverrides",
"상단측(측구 방향)",
),
(
ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts",
"flushPendingStructures",
"structuresPart",
"구조물",
),
(
ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts",
"flushCulvertOptions",
"culvertOptionsPart",
"관 옵션",
),
]
#: 비었을 때 빠져나가는 모양 — `return;` 만 있는 이른 반환.
_EARLY_RETURN = re.compile(r"\breturn\s*;")
#: 비었을 때 빠져나가는 모양 — 빈 조각(`return null;`) 이른 반환.
_EARLY_RETURN = re.compile(r"\breturn\s+null\s*;")
def _body(path: Path, name: str) -> str:
@@ -69,21 +65,20 @@ def test_저장이_창구를_다섯_다_거친다():
B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다."""
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
for token in (
"flushCulvertOptions(",
"flushRouteParts(", # 상단측 차례 = 종단 보기 몫과 같은 본문(463)
"flushPendingPipes(",
"flushPendingStructures(",
"culvertOptionsPart(",
"body: await routePartsBody(projectId),", # 상단측 차례 = 종단 보기 몫과 같은 본문(463)
"pipesPart(",
"structuresPart(",
):
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
# 472-6 — 노선 안 바뀐 저장은 한 본문 조각(조정창 구간값 = 세션 `culvertopt`) · 노선 바뀐
# 저장은 위 창구 차례 그대로
# 472-6 — 노선이 바뀌든 안 바뀌든 한 본문 조각(조정창 구간값 = 세션 `culvertopt` · 옛 창구 차례 걷음)
assert 'culvertOptionsPart(stateKey("culvertopt", projectId, routeId))' in save, (
"B06 조정창 구간값이 한 본문 저장에서 빠짐"
)
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨) · 한 본문은 서버가 차례 이름째 되돌림
assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐"
# 차례가 실패하면 서버가 차례 이름째 되돌림(429 ⑨ · 전부 아니면 없음)
assert "STEP_LABEL[error.step]" in save, "한 본문 실패 차례 이름이 빠짐"
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
api = (ROOT / "A00_Common" / "design" / "design_save_api.ts").read_text(encoding="utf-8")
order = api[api.index("SAVE_PART_KEYS = [") :]
order = order[: order.index("]")]
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), (
+2 -2
View File
@@ -38,8 +38,8 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
shared = _body(SHARED.read_text(encoding="utf-8"), "export async function saveSharedDesign")
assert "prepare: crossSave?.prepare" in shared, "한 벌이 재계산을 저장 한 줄기에 안 넘긴다"
save = SAVE.read_text(encoding="utf-8")
# 472-6 — 노선 안 바뀐 저장 = 한 본문(`saveOneBody`) · 재계산 뒤에 횡단 조각을 모음
run = _body(save, "async function saveOneBody", 4000)
# 472-6 — [저장] = 한 본문(`saveDesign` · 노선 바뀜도) · 재계산 뒤에 횡단 조각을 모음
run = _body(save, "export async function saveDesign", 4000)
assert run.index("ctx.prepare?.()") < run.index("ctx.crossPart(confirm)"), (
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
)
+38 -16
View File
@@ -10,6 +10,7 @@ Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린
import json
import os
import re
import subprocess
import pytest
@@ -48,8 +49,14 @@ def test_계획고_가게_한_벌(out):
def test_저장_차례(out):
assert out["order"] == [
"route",
# 472-6 — 차례 = 한 본문 칸(브라우저) · 서버 한 창구 · 노선(346-3 · 6-B)은 서버 맨 앞
api = open(
os.path.join(HERE, "..", "..", "A00_Common", "design", "design_save_api.ts"),
encoding="utf-8",
).read()
keys = api[api.index("SAVE_PART_KEYS = [") :]
keys = re.findall(r'"([A-Za-z]+)"', keys[: keys.index("]")])
assert keys == [
"settings", # 448 페이지 설정 — 노선 뒤
"pipes",
"structures",
@@ -58,7 +65,13 @@ def test_저장_차례(out):
"culvertOptions",
"uphill",
"cross",
] # 346-3 노선 먼저
]
server = open(
os.path.join(HERE, "..", "..", "B06_Section", "B06_Section_Router_DesignSave.py"),
encoding="utf-8",
).read()
at = [re.search(rf'step\(\s*"{key}"', server).start() for key in ["route", *keys]]
assert at == sorted(at)
assert out["saveOk"] is True
# 472-6 — 노선 초안 없으면 조각을 모아 한 요청(서버 한 창구 · 전부 아니면 없음) ·
# 저장 뒤 조각 정리는 차례대로 · 본문 없는 조각(추가 측점 초안 없음)도 정리는 돎
@@ -97,19 +110,21 @@ def test_초기화는_초안과_한_벌을_버림(out):
def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
"""346-3 — 노선 초안이 있으면 맨 앞 · 관 · 구조물 기본 창구 · 계획고 초안(옛 노선 7 칸)을 새 노선 9 로 ·
화면 몫 일(옛 노선 판)은 건너뜀 · 횡단 초안 넘김(측점 열쇠) · 결과 한 줄 · 다시 세움."""
"""346-3 · 472-6 6-B — 노선 초안이 있으면 같은 한 본문에 `route` · `carry`(옛 노선 7 세션 칸) · 관 · 구조물
기본 조각 · 계획고 초안(옛 노선 7 칸 · 서버가 새 노선 9 로) · 상단측 안 보냄(요구 274) · 화면 몫 일(옛 노선 판)은
건너뜀 · 횡단 = 넘김 조각 · 다 저장한 뒤 정리 · 결과 한 줄 · 다시 세움."""
assert out["routeOk"] is True
assert out["routeCalls"] == [
"overlay:on",
"prepare",
"route",
"pipes",
"structures",
"plusStations",
'alignment:9:{"30.000":0.2}',
"culvert:aislo:draft:culvertopt:p1:7",
"carry:7->9", # 상단측은 넘김(carryAfterRouteSave)이 새 측점으로 옮긴 뒤 보냄(443-2)
"post:7:false:pipes,structures,alignment,culvertOptions,cross:route(carry 7)",
'alignment:7:{"30.000":0.2}',
"routeSaved",
"pipes:after",
"structures:after",
"plusStations:after",
"culvert:after:aislo:draft:culvertopt:p1:7",
"cross:carried:after",
"summary:9:2",
"toast:info",
"done:false",
@@ -121,24 +136,31 @@ def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
def test_노선이_그대로면_안_보냄(out):
assert "route" not in out["saveCalls"]
assert not any("route" in call for call in out["saveCalls"])
def test_노선_저장_실패는_멈추고_초안_남음(out):
assert out["routeFailOk"] is False
assert out["routeFailCalls"] == ["overlay:on", "route", "failed", "overlay:off"]
assert out["routeFailCalls"] == [
"overlay:on",
"post:7:false:pipes,structures,alignment,culvertOptions,cross:route(carry 7)",
'alignment:7:{"50.000":0.3}',
"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 calls[1].startswith("post:7:true:") and calls[1].endswith(":route(carry 7)")
assert calls[-2:] == ["rebuild", "overlay:off"]
assert "done:true" not in calls and "cross:true" not in calls
def test_노선_초안_칸_계획고가_새_노선으로(out):
"""346-3 덧 — 노선 초안 동안 계획고는 노선 0 칸(sub7) · 노선 저장 뒤 그 칸이 새 노선 9 로 · 0 · 7 칸 다 비움."""
assert out["draftRouteCalls"] == ['alignment:9:{"35.000":0.4}']
assert out["draftRouteCalls"] == ['alignment:7:{"35.000":0.4}'] # 서버가 새 노선 9 로
assert out["draftRouteLeft"] == {"draft": None, "old": None}
design = os.path.join(HERE, "..", "..", "A00_Common", "design")
with open(os.path.join(design, "design_pipeline_page.ts"), encoding="utf-8") as f: