From 58be37b0d5d6bf9f00b0fa163bfb88953dc692b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 07:41:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(466=20=EB=8D=A7=20=C2=B7=20470=20N1=20N2):?= =?UTF-8?q?=20[=ED=99=95=EC=A0=95]=20=EC=84=B1=EA=B3=B5=20=EB=92=A4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=B9=84=EC=9B=80=20=C2=B7=20B06=20?= =?UTF-8?q?=ED=8F=BC=20=EA=B4=80=20=EC=98=AE=EA=B9=80=20+=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=3D=20=EC=98=AE=EA=B8=B4=20=EA=B4=80=EC=97=90=20?= =?UTF-8?q?=EB=B6=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [확정] 성공 길도 [저장]처럼 invalidateSectionDetail — 다음 페이지(B07)가 정본을 받음 - B06 폼 onPipeUpdate — 옮긴 뒤 옵션 · 캐시 얹기 · 집계 항목을 옮긴 자리(맞춘 정수 m)로 찾음(옛: 옛 자리로 찾아 옵션 잃음 · 비운 옛 자리 그림에 얹음) - 시험 test_466_pipe_move_options · test_272_detail_memory 확정 항목 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_shared.ts | 2 + .../B06_Section_UI_Page_Structures_Panel.ts | 23 +- .../tester/helper_466_pipe_move_options.cjs | 254 ++++++++++++++++++ resources/tester/test_272_detail_memory.py | 8 + .../tester/test_466_pipe_move_options.py | 48 ++++ 5 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 resources/tester/helper_466_pipe_move_options.cjs create mode 100644 resources/tester/test_466_pipe_move_options.py diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 269a789fb..d6ec1a770 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -424,6 +424,8 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea done: async (isConfirm) => { markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) if (isConfirm) { + // 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1) + invalidateSectionDetail(shared.projectId); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); return; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 1260b2b47..1c9a1a4cd 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -298,12 +298,13 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 // (2026-09-12 일원화). 세부유역은 관 초안을 쓰면 배수유역도가 뒤에서 다시 나눈다(298-1). onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) - if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM); + // 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) · + // 옮긴 뒤 옵션 · 캐시 얹기는 옮긴 자리(맞춘 정수 m)로 찾음(옛 자리 = 비운 그림 · 470 N2) + const moved = Math.abs(fromChainageM - toChainageM) > 0.005; + const at = (moved ? movePipeTo(fromChainageM, toChainageM) : null) ?? fromChainageM; if (attributes.summary_item !== undefined || attributes.group_items !== undefined) { - // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M, + (entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M, ); const before = mine ? JSON.stringify(linkFields(mine)) : ""; if (mine && attributes.summary_item !== undefined) @@ -322,14 +323,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc return; } // 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이 - // [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준). - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); + // [저장] 때 옛 값으로 되살아난다. + const hit = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M); const was = hit?.facility ?? "pipe"; const facility = attributes.facility ?? was; // 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음 - if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch); + if (facility === was) deps.applyPipeOptions?.(at, patch); if (hit) { hit.options = mergeFacilityOptions( @@ -533,10 +532,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ - function movePipeTo(fromChainageM: number, toChainageM: number): void { + /** 관을 옮김 — 옮긴 자리(정수 m) · 그 자리 관이 없으면 null. */ + function movePipeTo(fromChainageM: number, toChainageM: number): number | null { if (!deps.projectId) { showToast(PIPE_MOVE_GUIDE, "error"); - return; + return null; } const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1) const hit = pipeFacilities.find( @@ -555,6 +555,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setPipeFacilities(pipeFacilities); pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); + return hit ? to : null; } /** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */ diff --git a/resources/tester/helper_466_pipe_move_options.cjs b/resources/tester/helper_466_pipe_move_options.cjs new file mode 100644 index 000000000..52aece35f --- /dev/null +++ b/resources/tester/helper_466_pipe_move_options.cjs @@ -0,0 +1,254 @@ +/* 466 덧(470 N2) 헬퍼 — 진짜 `createB06StructuresPanel` 의 B06 폼 관 고침(`onPipeUpdate`)이 관을 0.51 m 넘게 + * 옮기며 옵션도 고칠 때 옵션이 옮긴 관(초안)에 붙고 캐시 얹기도 새 자리인지. 가짜 모듈 틀 = helper_165_pavement_flow.cjs. + * 표준출력 JSON 한 벌 · 짝 = test_466_pipe_move_options.py. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +// 세션(structure-pick) — 메모리 한 벌 +const store = new Map(); +const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); + +const PID = "p165"; +const TYPES = [ + { type_id: "pavement_concrete", group: "G" }, + { type_id: "hazard_sign", group: "E" }, + { type_id: "pipe", group: "A", managed_by: "pipe_points" }, +]; +const PAVE = { + structure_id: "pave", + type_id: "pavement_concrete", + placement: "interval", + chainage_m: 30, + start_m: 30, + end_m: 250, +}; +const PAVE2 = { + structure_id: "pave2", + type_id: "pavement_concrete", + placement: "interval", + chainage_m: 100, + start_m: 100, + end_m: 200, +}; +const PIPES = [{ chainage_m: 80.17, facility: "pipe", source: "spacing", options: {} }]; +// 카드 = 20 m 측점 + 관 측점(비정규) +const CARDS = [...Array.from({ length: 16 }, (_, i) => i * 20), 80.17] + .sort((a, b) => a - b) + .map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const cardAt = (at) => CARDS.find((card) => Math.abs(card.chainage_m - at) < 0.01); + +/* ── 폼 흉내(B05 패널 · loadForm / loadPipeForm / resetForm 의 알림 차례) ── */ +let binding = null; +let structures = []; +const form = { id: null, pipe: null }; +const loadForm = (target) => { + form.id = target?.structure_id ?? null; + form.pipe = null; + binding.onSelect(target); +}; +const loadPipeForm = (chainageM) => { + const hadStructure = form.id !== null; + form.id = null; + form.pipe = chainageM; + if (hadStructure) binding.onSelect(null); +}; +const resetForm = () => { + const hadPipe = form.pipe !== null; + form.pipe = null; + loadForm(null); + if (hadPipe) binding.onPipeSelect(null); +}; +const node = { addEventListener() {}, querySelector: () => ({ append() {} }) }; +const section = { + root: node, + listRoot: node, + facility: { root: node, setRevetSideLabels() {}, recount() {} }, + setTypes() {}, + setStructures(next) { + structures = next; + }, + setPipeFacilities() {}, + hasSelection: () => form.id !== null || form.pipe !== null, + selectById(id) { + if (id === null) { + if (form.id !== null || form.pipe !== null) resetForm(); + return; + } + loadForm(structures.find((entry) => entry.structure_id === id) ?? null); + }, + selectPipeByChainage(chainageM) { + if (chainageM === null) { + if (form.pipe !== null) resetForm(); + return; + } + loadPipeForm(chainageM); + }, +}; + +const panelModule = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"), + { + "../A00_Common/design/design_shared": { + bindDesignView: (_shared, _view, parts) => { + binding = parts.structures; + }, + sharedStructures: () => section, + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructureTypes: async () => TYPES, + fetchStructures: async () => ({ structures: [] }), + readPendingStructures: () => current.structures, + pipesToStructureMarks: (pipes) => + pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + })), + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + writePendingStructures() {}, + dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0) + staleDefinitionNotice: () => null, // 판 알림(322-2) + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { + readPendingPipes: () => null, + writePendingPipes: (_pid, next) => (written = next), + }, + "../B05_Profile/logic/B05_Profile_UI_Drainage_Facility_Merge": { + mergeFacilityOptions: (was, next) => ({ + facility: next.facility, + options: { ...(was.options ?? {}), ...(next.options ?? {}) }, + }), + }, + "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill": { + fordWaitsOf: () => new Set(), + waitsFordHeight: () => false, + }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), + }, + // 페이지 자료 줄(386-2) — 저장분 한 번 + "../A00_Common/design/design_page_data": { + pageTypesChecked: async () => false, + pageStructures: async () => ({ structures: [] }), + pagePipes: async () => ({ pipe_points: PIPES, basins: [] }), + }, + "../common_util/common_util_summary_item": { + linkFields: () => ({}), + readGroupItems: (x) => x ?? {}, + readSummaryItem: (x) => x ?? null, + }, + "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": { readStructurePick: () => null }, + "../A00_Common/design/design_selection": selection, + "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, + "./B06_Section_UI_Adjust_Dock": { + adjustDockRoot: () => ({}), + refreshAdjustSlots() {}, + resetAdjustDock() {}, + setAdjustSideSlots() {}, + }, + "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../B05_Profile/B05_Profile_UI_Structures_Snap": { + snapNotice() {}, + snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }), + }, + "../A00_Common/design/design_pipe_ids": { + pipeIdAt: (at) => `pipe-${at.toFixed(2)}`, + followMovedPipe() {}, + }, + // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) + "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), + { + "../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로 + "@ui/ui_template_context_menu": { createMapContextMenu() {} }, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: () => false, + }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + "../A00_Common/design/design_selection": selection, + "../common_util/common_util_structure_station": loadTs( + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + ), + }, + ), + }, +); + +const current = { structures: [] }; +let panel = null; +let written = null; +const applied = []; + +(async () => { + const out = {}; + const run = async (from, to, options) => { + store.clear(); + PIPES.splice(0, PIPES.length, { + chainage_m: 80.17, + facility: "pipe", + source: "spacing", + options: { pipe_diameter_mm: 800 }, + }); + written = null; + applied.length = 0; + panel = panelModule.createB06StructuresPanel({ + projectId: PID, + shared: { view: () => "cross", lane: { set() {}, setTypes() {}, setStored() {} } }, + detail: () => ({ cross_sections: CARDS }), + stationInterval: () => 20, + focusChainage() {}, + markStructure() {}, + onMarks() {}, + applyPipeOptions: (at, patch) => applied.push([at, patch.pipe_diameter_mm ?? null]), + }); + await panel.load(); + binding.onPipeUpdate(from, to, { facility: "pipe", options }); + return { + pipes: (written ?? []).map((pipe) => [ + pipe.chainage_m, + pipe.options?.pipe_diameter_mm ?? null, + ]), + applied: [...applied], + }; + }; + out.far = await run(80.17, 100.4, { pipe_diameter_mm: 1200 }); // 옮김 + 옵션 + out.near = await run(80.17, 80, { pipe_diameter_mm: 1000 }); // 0.51 m 안 옮김 + 옵션 + out.stay = await run(80.17, 80.17, { pipe_diameter_mm: 900 }); // 옵션만 + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/test_272_detail_memory.py b/resources/tester/test_272_detail_memory.py index 1f447f018..1d5fdbc6d 100644 --- a/resources/tester/test_272_detail_memory.py +++ b/resources/tester/test_272_detail_memory.py @@ -107,3 +107,11 @@ def test_화면_선만_바뀌면_낡음_다시_셈() -> None: store = (ROOT / "B06_Section/B06_Section_Section_Store.ts").read_text(encoding="utf-8") fn = store[store.index("export function recrossIfStale(") :] assert "recrossSaved(projectId, key, detail, routeId)" in fn[: fn.index("\n}\n")] + + +def test_확정_성공도_상세_비움() -> None: + # 470 N1 — [확정] 은 다음 페이지(B07)로 바로 감 · [저장]처럼 상세 메모리 · 옛 세션 사본을 비움 + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + done = shared[shared.index(" done: async (isConfirm) => {") :] + confirm = done[done.index("if (isConfirm) {") : done.index("goToWorkflowStage(")] + assert "invalidateSectionDetail(shared.projectId);" in confirm diff --git a/resources/tester/test_466_pipe_move_options.py b/resources/tester/test_466_pipe_move_options.py new file mode 100644 index 000000000..b55a23e17 --- /dev/null +++ b/resources/tester/test_466_pipe_move_options.py @@ -0,0 +1,48 @@ +"""PLAN 466 덧(470 조사 4.2 N2) — B06 폼 관 고침: 0.51 m 넘게 옮기며 옵션도 고치면 옵션이 옮긴 관에 붙음. + +못박는 것 + - 옮김 + 옵션 = 관 초안(가게 pipes)의 옮긴 관에 새 옵션 · 캐시 얹기(`applyPipeOptions`)도 옮긴 자리. + - 0.51 m 안 옮김 · 옵션만 = 그 관에 새 옵션 · 얹기 = 지금 자리. +짝: `helper_466_pipe_move_options.cjs`(진짜 `createB06StructuresPanel` · 가짜 모듈 틀 = 165 헬퍼). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_466_pipe_move_options.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_466_pipe_move_options.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_멀리_옮기며_옵션_고침(got) -> None: + assert got["far"]["pipes"] == [[100, 1200]] # 옛: [[100, 800]] — 옵션이 옛 자리로 찾아 안 붙음 + assert got["far"]["applied"] == [[100, 1200]] # 옛: 80.17(비운 옛 자리 그림) + + +def test_가까이_옮김_옵션만(got) -> None: + assert got["near"]["pipes"] == [[80, 1000]] and got["near"]["applied"] == [[80, 1000]] + assert got["stay"]["pipes"] == [[80.17, 900]] and got["stay"]["applied"] == [[80.17, 900]]