From 8d577ef330b9a51403d5bb94fa3af0d7fc11ddfe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 15:33:08 +0900 Subject: [PATCH] =?UTF-8?q?refactor(472=20=EB=A7=88=EB=AC=B4=EB=A6=AC):=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=EB=8F=84=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20[?= =?UTF-8?q?=EC=A0=80=EC=9E=A5]=20=EC=A1=B0=EA=B0=81=EC=9D=84=20design=5Fpr?= =?UTF-8?q?ofile=5Fsave=20=EB=A1=9C=20=EB=96=BC=EC=96=B4=20=EB=83=84=20?= =?UTF-8?q?=E2=80=94=20design=5Fprofile=20708=20=E2=86=92=20683=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EB=8F=99=EC=9E=91=20=EA=B0=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_profile_save.ts 새 파일 — 한 본문 [저장] 조각 · [확정] 직전 저장 · 저장 뒤 서버 선형 넘김 - 높이 캐스케이드 설명을 B05_Profile_UI_Profile_Heights 머리로 옮김 - 팝아웃 시험 목록에 새 파일 · 저장 조각 시험(test_472_6_profile_save) 추가 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_profile.ts | 71 ++++++------------- A00_Common/design/design_profile_save.ts | 52 ++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Heights.ts | 9 +++ .../tester/helper_472_6_profile_save.cjs | 56 +++++++++++++++ resources/tester/test_230_2_popout_frames.py | 1 + resources/tester/test_472_6_profile_save.py | 30 ++++++++ 6 files changed, 171 insertions(+), 48 deletions(-) create mode 100644 A00_Common/design/design_profile_save.ts create mode 100644 resources/tester/helper_472_6_profile_save.cjs create mode 100644 resources/tester/test_472_6_profile_save.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index edeeafe90..79421bcc5 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -23,11 +23,6 @@ import { toDesignProfile, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { showToast } from "@ui/ui_template_elements"; -import { - saveProfileAlignment, - type ProfileAlignmentSaveResponse, -} from "../../B05_Profile/B05_Profile_Api_Fetch"; -import type { SavePart } from "./design_save_api"; import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common"; import { blocksMinCover, @@ -88,12 +83,12 @@ import * as pagePipeline from "./design_pipeline_page"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window"; import type { DesignSideBoard, DesignView } from "./design_profile_slots"; -import { noticeDroppedEdits } from "./design_save"; import { createProfileOverlays } from "./design_profile_overlays"; import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport"; import { CROSS_PREVIEW_DEBOUNCE_MS, type DesignProfileContext } from "./design_profile_context"; import { pageRouteId } from "./design_page_route"; import { createProfileFrame } from "./design_profile_frame"; // 판 뼈대 · 종단 CSS(700줄 한계) +import { createProfileSave } from "./design_profile_save"; // 계획고 [저장] 조각(700줄 한계) export type { DesignOverlayBoard, @@ -303,29 +298,26 @@ export function createDesignProfile(ctx: DesignProfileContext) { const listed = (): AlignmentBase | null => listedBase(storedBase, detail, planted ? irregularStations : null); + /** 계획고 [저장] 조각(472-6) — 저장 뒤 서버 선형으로 기준을 갈아 다시 그림. */ + const alignmentSave = createProfileSave({ + projectId, + routeId: () => routeId, + edits: () => store.edits(), + dirty: () => store.dirty(), + previewing: () => store === preview, // 노선 초안 미리보기 편집은 안 보냄 + adopt: (next) => { + if (next?.base_pvi?.length) { + storedBase = toAlignmentBase(next); + base = listed(); + alignment = buildAlignment(base!, store.edits()); + if (detail) detail.longitudinal.profile_alignment = next; + } + store.markSaved(); + draw(); + }, + }); + /** 편집 후 다시 그린다 — 길게 누르기(초당 10회)도 한 프레임에 한 번만 그림. */ - /** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */ - function alignmentSavePart(): SavePart | null { - if (!routeId || !store.dirty() || store === preview) return null; // 노선 초안 미리보기 편집은 안 보냄 - return { - body: { route_id: routeId, ...store.edits() }, - after: (response) => adoptSaved(response as unknown as ProfileAlignmentSaveResponse), - }; - } - - function adoptSaved(saved: ProfileAlignmentSaveResponse): void { - noticeDroppedEdits(saved); - const next = saved.profile_alignment as ProfileAlignment | undefined; - if (next?.base_pvi?.length) { - storedBase = toAlignmentBase(next); - base = listed(); - alignment = buildAlignment(base!, store.edits()); - if (detail) detail.longitudinal.profile_alignment = next; - } - store.markSaved(); - draw(); - } - function rebuild(): void { if (base) alignment = buildAlignment(base, store.edits()); pagePipeline.pipelineGrade(projectId, store.edits()); @@ -361,15 +353,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 노선 초안 동안은 저장 노선 횡단을 안 고침 — 새 노선 횡단은 계산 묶음 cross(346-2) const scheduleCrossPreview = (): void => void (store !== preview && crossPreview.schedule()); - /** - * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 - * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 - * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 - * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). - * - * draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는 - * 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다. - */ + /** 높이 캐스케이드 — 규칙은 `_Profile_Heights` 머리(700줄 한계). */ const heights = createHeightCascade({ root, body, @@ -642,18 +626,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { drawSoon(); }, /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ - async save(): Promise { - const part = alignmentSavePart(); - if (!part) return; - await part.after?.( - (await saveProfileAlignment(projectId, routeId!, store.edits())) as unknown as Record< - string, - unknown - >, - ); - }, + save: () => alignmentSave.save(), /** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */ - savePart: () => alignmentSavePart(), + savePart: () => alignmentSave.savePart(), clear() { detail = null; base = null; diff --git a/A00_Common/design/design_profile_save.ts b/A00_Common/design/design_profile_save.ts new file mode 100644 index 000000000..a9bdd2808 --- /dev/null +++ b/A00_Common/design/design_profile_save.ts @@ -0,0 +1,52 @@ +/* ============================================================================= + * A00_Common/design/design_profile_save.ts + * 종단도 계획고 편집 저장 — 한 본문 [저장] 조각(472-6) · [확정] 직전 따로 저장. + * 편집 없음 · 노선 초안 미리보기면 안 보냄 · 저장 뒤 서버 선형은 `adopt` 로 종단도에 넘김. + * `design_profile.ts` 에서 떼어 냄(700줄 한계). + * ========================================================================== */ + +import { + saveProfileAlignment, + type ProfileAlignmentSaveResponse, +} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import type { + AlignmentEdits, + ProfileAlignment, +} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import type { SavePart } from "./design_save_api"; +import { noticeDroppedEdits } from "./design_save"; + +export function createProfileSave(deps: { + projectId: string; + routeId: () => number | null; + edits: () => AlignmentEdits; + dirty: () => boolean; + /** 노선 초안 미리보기 편집 중 — 안 보냄. */ + previewing: () => boolean; + /** 서버가 돌려준 선형(없을 수 있음) — 기준 갈아끼움 · 저장 표시 · 다시 그림. */ + adopt: (saved: ProfileAlignment | undefined) => void; +}) { + /** 한 본문 [저장] 조각 — 편집 없음 · 노선 초안 미리보기면 null. */ + function savePart(): SavePart | null { + const routeId = deps.routeId(); + if (!routeId || !deps.dirty() || deps.previewing()) return null; + return { + body: { route_id: routeId, ...deps.edits() }, + after: (response) => { + const saved = response as unknown as ProfileAlignmentSaveResponse; + noticeDroppedEdits(saved); + deps.adopt(saved.profile_alignment as ProfileAlignment | undefined); + }, + }; + } + + /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ + async function save(): Promise { + const part = savePart(); + if (!part) return; + const saved = await saveProfileAlignment(deps.projectId, deps.routeId()!, deps.edits()); + await part.after?.(saved as unknown as Record); + } + + return { savePart, save }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index fe6035427..1086cf8cc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -60,6 +60,15 @@ export interface HeightCascade { scheduleLightSync: () => void; } +/** + * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 + * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 + * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 + * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). + * + * 종단도 draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는 + * 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다. + */ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { let lightSyncPending = false; diff --git a/resources/tester/helper_472_6_profile_save.cjs b/resources/tester/helper_472_6_profile_save.cjs new file mode 100644 index 000000000..d18d8de01 --- /dev/null +++ b/resources/tester/helper_472_6_profile_save.cjs @@ -0,0 +1,56 @@ +/* 472 마무리 — 종단도 계획고 [저장] 조각(`design_profile_save.ts`) 진짜 본문 · 서버 창구 · 알림만 가짜. + * 결과 = 표준출력 JSON. 짝 = `test_472_6_profile_save.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const calls = []; +const STUBS = { + B05_Profile_Api_Fetch: { + saveProfileAlignment: async (projectId, routeId, edits) => { + calls.push(["put", projectId, routeId, edits]); + return { profile_alignment: { base_pvi: [1] }, dropped_edit_chainages: [] }; + }, + }, + design_save: { + noticeDroppedEdits: (saved) => calls.push(["notice", saved.dropped_edit_chainages]), + }, +}; +const file = path.join(ROOT, "A00_Common", "design", "design_profile_save.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +const req = (request) => STUBS[Object.keys(STUBS).find((name) => request.endsWith(name))] ?? {}; +new Function("exports", "module", "require", js)(box.exports, box, req); + +(async () => { + const state = { routeId: 7, dirty: true, previewing: false }; + const adopted = []; + const edits = { station_offsets: { 10: 0.5 }, curve_radii: {} }; + const save = box.exports.createProfileSave({ + projectId: "p", + routeId: () => state.routeId, + edits: () => edits, + dirty: () => state.dirty, + previewing: () => state.previewing, + adopt: (next) => adopted.push(next ?? null), + }); + const nulls = {}; + for (const [name, patch] of [ + ["clean", { dirty: false }], + ["preview", { previewing: true }], + ["noRoute", { routeId: null }], + ]) { + Object.assign(state, { routeId: 7, dirty: true, previewing: false }, patch); + nulls[name] = save.savePart(); + } + Object.assign(state, { routeId: 7, dirty: true, previewing: false }); + const part = save.savePart(); + await part.after({ profile_alignment: { base_pvi: [2] }, dropped_edit_chainages: [3] }); + await save.save(); + state.dirty = false; + await save.save(); // 편집 없음 = 안 보냄 + console.log(JSON.stringify({ nulls, body: part.body, adopted, calls })); +})(); diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py index 891881d19..3a4678554 100644 --- a/resources/tester/test_230_2_popout_frames.py +++ b/resources/tester/test_230_2_popout_frames.py @@ -22,6 +22,7 @@ ROOT = os.path.dirname(os.path.dirname(HERE)) # 새 창으로 가는 종단 패널의 화면 코드 POPOUT_FILES = [ "A00_Common/design/design_profile.ts", + "A00_Common/design/design_profile_save.ts", "A00_Common/design/design_profile_viewport.ts", "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", "B05_Profile/B05_Profile_UI_Profile_Heights.ts", diff --git a/resources/tester/test_472_6_profile_save.py b/resources/tester/test_472_6_profile_save.py new file mode 100644 index 000000000..66aca1c58 --- /dev/null +++ b/resources/tester/test_472_6_profile_save.py @@ -0,0 +1,30 @@ +"""472 마무리 — 종단도 계획고 [저장] 조각을 `design_profile_save.ts` 로 떼어 냄(700줄 한계 · 동작 같음). + +`helper_472_6_profile_save.cjs` 가 진짜 본문을 돌림 — 편집 없음 · 노선 초안 미리보기 · 노선 없음 = 조각 없음 · +조각 본문 = 노선 id + 편집 · 저장 뒤 버린 자리 알림 → 서버 선형 넘김 · [확정] 직전 저장 = 같은 창구 한 번. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_profile_save.py -q +""" + +import json +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def test_계획고_저장_조각() -> None: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_472_6_profile_save.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + r = json.loads(proc.stdout) + edits = {"station_offsets": {"10": 0.5}, "curve_radii": {}} + assert r["nulls"] == {"clean": None, "preview": None, "noRoute": None} + assert r["body"] == {"route_id": 7, **edits} + assert r["adopted"] == [{"base_pvi": [2]}, {"base_pvi": [1]}] + assert r["calls"] == [["notice", [3]], ["put", "p", 7, edits], ["notice", []]]