From 7f3aa6da6564035b7582c22595397fdbf3ae214f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 15:47:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(429=20[=EC=A0=80=EC=9E=A5]=20=EA=B8=B8)?= =?UTF-8?q?:=20=EB=85=B8=EC=84=A0=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EC=A0=80=EC=9E=A5=20=EB=8A=98=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=88=20=C2=B7=20=EC=83=81=EB=8B=A8=EC=B8=A1=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B0=92=20=EB=82=A8?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EC=B6=94=EA=B0=80=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=3D=20=EA=B3=84=EC=82=B0=20=EC=A4=84=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EC=B0=A8=EB=A1=80=20=EC=8B=A4=ED=8C=A8=20=3D=20?= =?UTF-8?q?=EB=A9=88=EC=B6=A4=EA=B3=BC=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ④ 노선 저장 뒤 횡단 저장 = 고침 없어도 한 번(뒤에 나간 계획고 초안이 횡단 설계 높이에 실림) - ⑤ 상단측 다시 셈 = 깎기 경사 · 소단을 기하 입력으로 · 사용자 조작값 목록째 승계 · 측구 끔(None) 저장분 셈 - ⑨ 추가 측점 = 묶음 입력 plusStations(stations 부터) · 가게 초안 · 되돌리기 → 묶음 - ⑨ [저장] 차례 실패 = 그 차례에서 멈춤 · 차례 이름째 알림(뒤 차례 안 보냄) - 시험 test_429_save_path · test_design_store_save 멈춤 규칙으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../design/design_pipeline_facilities.ts | 5 +- A00_Common/design/design_pipeline_page.ts | 9 +- A00_Common/design/design_save.ts | 98 ++++++------ A00_Common/design/logic/design_pipeline.ts | 4 +- .../design/logic/design_pipeline_types.ts | 12 +- B05_Profile/B05_Profile_Router_Confirm.py | 14 +- B06_Section/B06_Section_Section_Store.ts | 11 +- resources/tester/helper_429_save_path.cjs | 130 ++++++++++++++++ resources/tester/helper_design_store_save.cjs | 6 +- resources/tester/test_429_save_path.py | 144 ++++++++++++++++++ resources/tester/test_design_store_save.py | 17 +-- 11 files changed, 373 insertions(+), 77 deletions(-) create mode 100644 resources/tester/helper_429_save_path.cjs create mode 100644 resources/tester/test_429_save_path.py diff --git a/A00_Common/design/design_pipeline_facilities.ts b/A00_Common/design/design_pipeline_facilities.ts index 7df48aef7..8aa74f311 100644 --- a/A00_Common/design/design_pipeline_facilities.ts +++ b/A00_Common/design/design_pipeline_facilities.ts @@ -1,6 +1,6 @@ /* ============================================================================= * A00_Common/design/design_pipeline_facilities.ts - * 계산 묶음 관 · 구조물 먹이기(PLAN 415) — 가게 관 · 구조물 초안이 바뀌면 묶음에 넣음(비면 저장분). + * 계산 묶음 관 · 구조물 · 추가 측점 먹이기(PLAN 415 · 429 ⑨) — 가게 초안이 바뀌면 묶음에 넣음(비면 저장분). * · [저장] 도중 = 안 넣음(H2) — 초안이 비는 사이 열 때 읽은 옛 저장분으로 돌지 않게 · 끝나면 `resync` * (저장 목록 다시 읽기 · 입력 맞춤) 한 번. * · 되돌리기 · 다시하기 = 세션만 되쓰고 가게 알림이 없음 → 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은 @@ -12,10 +12,11 @@ import { onPageHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History" import type { useDesignStore } from "./design_store"; import type { ChangeSource } from "./logic/design_pipeline_types"; -type Key = "pipes" | "structures"; +type Key = "pipes" | "structures" | "plusStations"; const FACILITIES: ReadonlyArray<{ source: ChangeSource; key: Key }> = [ { source: "pipe", key: "pipes" }, { source: "structure", key: "structures" }, + { source: "plus", key: "plusStations" }, ]; export interface FacilityFeed { diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 94759324e..3103c59f7 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -58,6 +58,7 @@ import { readAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { plusStationsOf } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History"; import { analysisTerrain, @@ -533,6 +534,7 @@ function createPagePipeline( cross: crossNow()?.input ?? null, pipes: store.get("pipes") ?? savedPipes, structures: store.get("structures") ?? savedStructures, + plusStations: plusStationsOf(projectId), }; }; const start = (): void => { @@ -625,7 +627,12 @@ function createPagePipeline( store, ready: () => started && !echo, gone: () => disposed, - saved: (key) => (key === "pipes" ? savedPipes : savedStructures), + saved: (key) => + key === "pipes" + ? savedPipes + : key === "structures" + ? savedStructures + : plusStationsOf(projectId), input: (key) => pipe.input(key), routed: () => pipe.input("route") !== null || store.get("routeDraft") !== null, feed: (source, value) => void feed(source, value), diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index a1fb71aec..b5dd3183b 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -9,6 +9,8 @@ * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). + * 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄). + * 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④). * * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, @@ -86,12 +88,22 @@ export function savingDesign(projectId: string | null): boolean { return projectId !== null && saving.has(projectId); } -/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ -function reportOnly(label: string): (error: unknown) => void { - return (error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${label} 저장에 실패했습니다.${detail}`, "error"); - }; +/** 차례 이름 — 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게 · + * 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ +const STEP_LABEL: Record = { + route: "노선", + pipes: "배수관", + structures: "구조물", + plusStations: "추가 측점", + alignment: "계획선", + culvertOptions: "암거 옵션", + uphill: "상단측", + cross: "횡단", +}; + +function stepFailed(step: DesignSaveStep, error: unknown): Error { + const detail = error instanceof Error ? ` ${error.message}` : ""; + return new Error(`${STEP_LABEL[step]} 차례에서 멈췄습니다(뒤 차례 안 보냄).${detail}`); } /** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */ @@ -128,15 +140,7 @@ async function flushAlignmentDraft( if (moved) markAlignmentSaved(DRAFT_ROUTE_ID); } -/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */ -const TOLERANT: Partial> = { - pipes: "배수관", - structures: "구조물", - plusStations: "추가 측점", - alignment: "계획선", -}; - -/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */ +/** 차례마다 기본 창구. */ function defaultStep( step: Exclude, projectId: string, @@ -153,12 +157,9 @@ function defaultStep( case "alignment": return () => flushAlignmentDraft(projectId, routeId, fromRouteId); case "culvertOptions": - return () => - flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch( - () => undefined, - ); + return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)); case "uphill": - return () => flushUphillOverrides(projectId).catch(() => undefined); + return () => flushUphillOverrides(projectId); } } @@ -201,35 +202,38 @@ export async function saveDesign( try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { - if (step === "route") { - const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 - if (!body) continue; - const before = await savedPipesIfDraft(ctx.projectId); - routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) - if (routeSaved.route_id != null) routeId = routeSaved.route_id; - if (before) await rebasePendingPipes(ctx.projectId, before); - continue; - } - if (step === "cross") { - if (!routeSaved) await ctx.cross(confirm); - else { - const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); - showToast(routeSaveSummary(routeSaved, outside), "info"); + try { + if (step === "route") { + const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 + if (!body) continue; + const before = await savedPipesIfDraft(ctx.projectId); + routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) + if (routeSaved.route_id != null) routeId = routeSaved.route_id; + if (before) await rebasePendingPipes(ctx.projectId, before); + continue; } - continue; + if (step === "cross") { + if (!routeSaved) await ctx.cross(confirm); + else { + const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); + showToast(routeSaveSummary(routeSaved, outside), "info"); + } + continue; + } + // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. + const own = routeSaved ? undefined : ctx.steps?.[step]; + const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); + const run = + step === "pipes" + ? async () => { + await base(); + await own?.(); + } + : (own ?? base); + await run(); + } catch (error) { + throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨) } - // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. - const own = routeSaved ? undefined : ctx.steps?.[step]; - const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); - const run = - step === "pipes" - ? async () => { - await base(); - await own?.(); - } - : (own ?? base); - const label = TOLERANT[step]; - await (label ? run().catch(reportOnly(label)) : run()); } clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 diff --git a/A00_Common/design/logic/design_pipeline.ts b/A00_Common/design/logic/design_pipeline.ts index 1dea2fe29..970dc5384 100644 --- a/A00_Common/design/logic/design_pipeline.ts +++ b/A00_Common/design/logic/design_pipeline.ts @@ -50,7 +50,7 @@ type Dep = StageName | `input.${InputKey}`; export const STAGE_DEPS: Record = { route: ["input.route"], follow: ["route"], - stations: ["route", "input.pipes", "input.structures"], + stations: ["route", "input.pipes", "input.structures", "input.plusStations"], ground: ["stations"], planline: ["ground"], profile: ["planline", "input.alignment"], @@ -152,6 +152,7 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig cross: null, pipes: null, structures: null, + plusStations: null, }; const inputVer: Record = { route: 0, @@ -159,6 +160,7 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig cross: 0, pipes: 0, structures: 0, + plusStations: 0, }; const out = emptyOuts(); const outVer = {} as Record; diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index aa8b9045b..217735186 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -5,7 +5,8 @@ * 계산 묶음 본체 = `design_pipeline.ts`. * * 흐름 — 한 방향: - * 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물) + * 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물) · + * plusStations(추가 측점 · 429 ⑨) * ↓ update(출처, 값, 범위) — 조작마다 차례 번호(generation) +1 · 옛 계산 · 옛 요청 버림 * 브라우저 즉시(차례대로 · 단계 사이마다 번호 확인 · 단계마다 결과 바로 내보냄) * route → follow → stations → ground → planline → profile → cross → footprint → masshaul @@ -24,7 +25,7 @@ * | 단계 | 주인 | deps | 출력 | * | route | sub8 | input.route | RouteLine | * | follow | sub1 | route | FollowPatch(입력 고침) | - * | stations | sub7 | route · input.pipes · input.structures | PipelineStation[](지반 null) | + * | stations | sub7 | route · input.pipes · input.structures · input.plusStations | PipelineStation[](지반 null) | * | ground | sub8 | stations | GroundOutput | * | planline | sub1 | ground | AlignmentBase(첫 계획선) | * | profile | sub1 | planline · input.alignment | ProfileAlignment | @@ -89,12 +90,14 @@ export interface PipelineInputs { pipes: unknown; /** 구조물 목록(`StructureInstance[]`) — 위와 같음. */ structures: unknown; + /** 추가 측점(정수 m · 초안 ?? 저장분) — 바뀌면 stations 부터(429 ⑨) · 셈은 `plusStations()` 가 같은 값을 읽음. */ + plusStations?: ReadonlyArray | null; } export type InputKey = keyof PipelineInputs; -/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물. */ -export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure"; +/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물 · 추가 측점. */ +export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure" | "plus"; /** 출처 → 입력 칸. */ export const SOURCE_INPUT: Record = { @@ -103,6 +106,7 @@ export const SOURCE_INPUT: Record = { cross: "cross", pipe: "pipes", structure: "structures", + plus: "plusStations", }; /** diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index f2fdbccb3..98838815b 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -112,11 +112,14 @@ async def sync_uphill_overrides_into_designs( from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from B06_Section.B06_Section_Router_Design import ( + USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, pavement_suggestions, read_cross_design_inputs, resolve_longitudinal_path, + stored_berm, + stored_cut_slope, ) if not overrides: @@ -166,23 +169,26 @@ async def sync_uphill_overrides_into_designs( ground_type=str(design.get("ground_type", "soil")), section_mode=next_mode, ditch_side=side, - ditch_type=str(design.get("ditch_type", "standard")), + ditch_type=str(design.get("ditch_type") or "standard"), # 측구 끔 = None 저장분 paved=bool(design.get("paved", False)), standard=stored_standard, rock_boundary_offset_m=design.get("rock_boundary_offset_m"), two_stage_slope=bool(design.get("two_stage_slope", False)), + cut_slope_ratio=stored_cut_slope(design), # 측점별 깎기 경사(429 ⑤) ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), surface_drop_m=ford_drop_at(float(chainage), ford_drops), + berm=stored_berm(design), # 소단 · 쪽마다 옮긴 깊이(429 ⑤) **curve_widening_args(cross_record), **stored_shoulder_extend(design), # 노견 확장(139-6) **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) - # 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다. - if design.get("display_half_width_m") is not None: - next_design["display_half_width_m"] = design["display_half_width_m"] + # 사용자 조작값 승계(조절 · 소단 · 깎기 경사 · 글) — `enforce_design_lines` 와 같음 + for key in USER_TOUCHED_KEYS: + if design.get(key) is not None: + next_design[key] = design[key] out.append((float(chainage), next_design)) return out diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 8d93793f6..6fa61ecd4 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -353,15 +353,14 @@ export function crossPatchesFromCache( } /** - * 캐시에 얹힌 사용자 수정분을 영구저장소에 남긴다(임시저장용). 캐시가 없거나 수정분이 - * 없으면 아무것도 보내지 않는다. 저장 실패는 호출자가 처리한다. + * 캐시에 얹힌 사용자 수정분을 영구저장소에 남긴다(노선 저장 뒤 · 임시저장용). 수정분이 없어도 + * 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈(429 ④ · 노선 뒤에 나간 계획고 초안). + * 저장 실패는 호출자가 처리한다. */ export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise { const detail = cache.get(keyOf(projectId, routeId)); - if (!detail) return 0; - const patches = crossPatchesFromCache(detail, kindChangedChainages(projectId)); - if (!patches.length) return 0; - await saveSections(projectId, routeId, undefined, patches); + const patches = detail ? crossPatchesFromCache(detail, kindChangedChainages(projectId)) : []; + await saveSections(projectId, routeId, undefined, patches.length ? patches : undefined); clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4) return patches.length; } diff --git a/resources/tester/helper_429_save_path.cjs b/resources/tester/helper_429_save_path.cjs new file mode 100644 index 000000000..bec0a82f2 --- /dev/null +++ b/resources/tester/helper_429_save_path.cjs @@ -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); + }, +); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 7f3e4aca7..31520794f 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -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 }, ); diff --git a/resources/tester/test_429_save_path.py b/resources/tester/test_429_save_path.py new file mode 100644 index 000000000..276fc7854 --- /dev/null +++ b/resources/tester/test_429_save_path.py @@ -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]]] # 되돌리기 = 입력과 다르면 넣음 diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 8a3bf9664..ba80df0fa 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -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"] == [] From e20881b8ad7c3c984dea0e27afca5fb4c7761e53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 15:48:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(429=20=EC=A0=80=EC=9E=A5=20=EC=B0=BD?= =?UTF-8?q?=EA=B5=AC):=20=EC=8B=A4=ED=8C=A8=20=EA=B2=A9=EB=A6=AC=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=EC=97=90=EC=84=9C=20=EB=A9=88=EC=B6=A4=EC=9D=84=20?= =?UTF-8?q?=EB=AA=BB=EB=B0=95=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- resources/tester/test_b06_save_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index b06e24837..44a84ff11 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -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"]