From 086ead9d6a9d12f1b67439adc6e449c677691b18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:53:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(472-5=205-A=20=EA=B0=92=20=EC=A3=BC?= =?UTF-8?q?=EC=9D=B8=20=ED=95=98=EB=82=98):=20=EA=B4=80=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A3=BC=EC=9D=B8=20=3D=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EA=B0=80=EA=B2=8C=20=EC=B9=B8=20=E2=80=94?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=82=AC=EB=B3=B8=EC=9D=80=20=EA=B0=80?= =?UTF-8?q?=EA=B2=8C=20=EC=95=8C=EB=A6=BC=EC=9C=BC=EB=A1=9C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=EB=8F=84=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 되돌리기 · 다시하기(세션 직접 되씀) 뒤 announceDesignStore — 관 · 구조물 · 추가 측점 듣는 쪽을 되돌린 값으로 (옛: 종단 목록 · 횡단 판 · 배수 편집기가 옛 값을 쥐고 다음 조작이 되돌림을 덮음) - 종단 보기 — 가게 관 → 배수 편집기 adoptPipes(숨어도 · 제 쓰기 메아리 빼고 · 옵션만 바뀐 것도) · 가게 구조물 → takeStructures(보일 때) - 횡단 보기 — 판이 마지막으로 읽거나 쓴 값과 다르면(옵션만도) 다시 실음(seen · foreign) · 제 쓰기는 쓰기 전에 적음 - 종단 고름 · 알약 관 = 시설 줄(가게 초안 자리 · 노선 따라감 뒤도) — 옛 알림 자리 안 봄 - 시험 test_472_5_shop_owner(옛 코드 실패 확인) · test_188_1 글 한 줄(까닭: 고름 관 원천 바뀜) · 위키 values · rules Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_store.ts | 11 ++ A00_Common/design/design_view_cross.ts | 6 + A00_Common/design/design_view_profile.ts | 14 ++- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 7 ++ .../B05_Profile_UI_Drainage_Panel_Types.ts | 8 +- B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 32 ++++- B05_Profile/B05_Profile_UI_Profile_History.ts | 3 +- .../B06_Section_UI_Page_Structures_Panel.ts | 37 +++--- ..._Section_UI_Page_Structures_Panel_Types.ts | 2 + docs/wiki/concepts/design_page/rules.md | 3 +- docs/wiki/concepts/design_page/values.md | 4 +- resources/tester/helper_472_5_shop.mjs | 93 +++++++++++++++ resources/tester/helper_472_5_shop_entry.ts | 6 + resources/tester/test_188_1_last_pick.py | 3 +- resources/tester/test_472_5_shop_owner.py | 109 ++++++++++++++++++ 15 files changed, 316 insertions(+), 22 deletions(-) create mode 100644 resources/tester/helper_472_5_shop.mjs create mode 100644 resources/tester/helper_472_5_shop_entry.ts create mode 100644 resources/tester/test_472_5_shop_owner.py diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 9d5b2c616..5bd9af3df 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -241,6 +241,17 @@ const listeners = new Map void>>(); * 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고 * 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다. */ +/** 가게를 안 거친 세션 되씀(되돌리기 · 다시하기) 뒤 — 프로젝트 목록 칸을 듣는 쪽을 지금 값으로 부름 + * (472-5 · 관 · 구조물 사본이 옛 값으로 남아 다음 조작이 되돌림을 덮던 흠). */ +export function announceDesignStore(projectId: string): void { + const store = useDesignStore(projectId, null); + (["structures", "pipes", "plusStations"] as const).forEach((key) => { + const id = slotOf(key, projectId, null).id; + const value = store.get(key); + if (id) listeners.get(`${key}|${id}`)?.forEach((fn) => fn(value)); + }); +} + export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore { return { get(key) { diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 7a7ad78dc..f8e5d8be3 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -18,6 +18,7 @@ import { stateKey, writeByKey } from "../b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { useDesignStore } from "./design_store"; import { pipeIdAt } from "./design_pipe_ids"; import { pageRouteId, pageSectionContext } from "./design_page_data"; import { @@ -575,6 +576,11 @@ export async function mountCrossView( void refreshDetailForStructures(); }), ); + // 가게 관 · 구조물 초안을 남이 고침(되돌리기 · 종단 보기 · 저장 뒤 · 옵션만도) — 보일 때 목록 · 폼 다시(472-5) + const shop = useDesignStore(projectId, null); + const onShop = (): void => void (host.visible() && structuresPanel.foreign() && reloadPanel()); + host.onDispose(shop.subscribe("structures", onShop)); + host.onDispose(shop.subscribe("pipes", onShop)); host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures)); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 41b74610c..885f9865a 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -116,6 +116,8 @@ export async function mountProfileView( /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ let lastPipes: PipeReport | null = shared.lastPipes ?? null; const designStore = useDesignStore(activeProjectId, null); + /** 화면 관 = 시설 줄(알림을 가게 초안 자리로 · 노선 따라감 뒤도) — 고름 · 알약이 옛 알림 자리를 안 봄(472-5). */ + const shownPipes = (): PipeReport => (lastPipes ? (shared.lane.pipes() as PipeReport) : []); /** 화면 관 목록 = 시설 줄 한 벌(462 · 가게 관 초안 측점 · 관 세션 id 장부 · 알약 · 측점선 · 흙덮기는 그쪽이 * 숨어도 셈) — 이 보기는 보일 때 좌측 목록만 맞춤. */ const applyPipes = (): void => { @@ -144,7 +146,7 @@ export async function mountProfileView( const selectGraphAt = (id: string | null, chainageM: number | null): void => { const structurePick = !!readSelection(activeProjectId)?.structureId; const sections = structurePick ? [] : (state.detail?.cross_sections ?? []); - const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); + const target = graphTargetAt(id, chainageM, shownPipes(), sections); if ("pipeChainageM" in target) return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId)); profilePanel.setSelectedStation(target.stationId); @@ -396,7 +398,7 @@ export async function mountProfileView( host, { irregular: () => bridge.irregularStations(), - pipes: () => lastPipes ?? [], + pipes: shownPipes, sections: () => state.detail?.cross_sections ?? [], }, ); @@ -436,6 +438,14 @@ export async function mountProfileView( // 추가 측점(368-2) — 초안 · 저장분이 바뀌면 시설 줄이 측점 계획을 다시 맞춤(종단 측점선 · 3D) setPlusStations(() => plusStationsOf(activeProjectId)); // 저장분이 늦게 와도 한 번 다시 맞춤 void loadSavedPlusStations(activeProjectId).then(() => bridge.resyncStations()); + // 가게 관 · 구조물 초안이 다른 길(횡단 보기 · 되돌리기 · 저장 뒤)로 바뀜 — 배수 편집기는 숨어도 받음(다음 + // 요청이 옛 값을 되쓰지 않게) · 목록은 보일 때(숨으면 다시 보일 때 load) · 제 쓰기는 같은 값이라 그냥 지남(472-5). + host.onDispose( + designStore.subscribe("pipes", (next) => next && profilePanel.drainage.adoptPipes(next)), + ); + host.onDispose( + designStore.subscribe("structures", (next) => host.visible() && bridge.takeStructures(next)), + ); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 2e7cc7526..816f28d60 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -58,6 +58,7 @@ import { basinIndexOfPipe, pipeMarkerColor, adoptDraftPipes, + adoptEditorPipes, followEditorPipes, reconcilePipes, WIDTH_KEY, @@ -537,6 +538,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }, pipeChainages: () => pipeEditor.chainages(), followPipes: (pipes) => followEditorPipes(pipeEditor, facilityStore, pipes), + adoptPipes(pipes) { + if (!adoptEditorPipes(pipeEditor, facilityStore, pipeRequest(), pipes)) return; + callbacks.onPipesChanged?.(buildPipeReports(pipeEditor.pipes(), basins, facilityStore)); + syncPipeSelection(false); + scheduleDraw(); + }, movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index f42956a42..3088b0ca3 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -10,7 +10,11 @@ import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; -import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { + DetailPipeInput, + PipeFacility, + PipeSource, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import type { StageFn } from "../A00_Common/design/logic/design_pipeline_types"; @@ -28,6 +32,8 @@ export interface DrainagePanel { pipeChainages: () => number[]; /** 노선 초안 follow 가 다시 매긴 관 자리를 받음(짝 = 관 id · 재계산 없음 · 410). */ followPipes: (pipes: ReadonlyArray<{ id?: string | null; chainage_m: number }>) => void; + /** 가게 관 초안을 그대로 받음(다른 보기 · 되돌리기가 고침 · 472-5) — 제 쓰기와 같으면 아무것도 안 함 · 재계산 없음. */ + adoptPipes: (pipes: ReadonlyArray) => void; /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ movePipe: (fromChainage: number, toChainage: number) => void; /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index acd8afb0b..1680299ea 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -20,7 +20,7 @@ import { } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; -import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { DetailBasin, DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; import type { FacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; @@ -333,6 +333,36 @@ export function mountDrainageToggles( /** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id(막 놓아 초안에 id * 없는 관끼리는 누가거리 차례 · 수가 같을 때만) · 시설 속성 · 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가 * 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */ +/** 가게 관 초안을 편집기 · 시설 보관소에 그대로 싣는다 — 다른 길(횡단 보기 · 되돌리기 · 저장 뒤 rebase)이 + * 초안을 고친 때(472-5 · 안 실으면 다음 요청이 옛 값을 초안에 되씀). `current` = 지금 요청 몸통 — + * 같으면 제 쓰기 메아리라 아무것도 안 함(false) · 고른 관은 같은 id(없으면 같은 자리)로 따라감. */ +export function adoptEditorPipes( + editor: PipeEditor, + facilities: FacilityStore, + current: ReadonlyArray, + next: ReadonlyArray, +): boolean { + if (JSON.stringify(current) === JSON.stringify(next)) return false; + const picked = editor.pipes()[editor.selected() ?? -1]; + facilities.replaceFromResponse(next); + editor.setPipes( + next.map((pipe) => ({ + chainage_m: pipe.chainage_m, + reason: pipe.source, + ...(pipe.id ? { id: pipe.id } : {}), + })), + ); + if (picked) { + const at = editor + .pipes() + .findIndex((pipe) => + picked.id ? pipe.id === picked.id : Math.abs(pipe.chainage_m - picked.chainage_m) < 0.005, + ); + if (at >= 0) editor.select(at); + } + return true; +} + export function followEditorPipes( editor: PipeEditor, facilities: FacilityStore, diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 1922c6f7d..37fe67636 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -28,7 +28,7 @@ * ========================================================================== */ import { projectStateKeys, readByKey, writeByKey } from "../A00_Common/b_page_state"; -import { draftProjectId } from "../A00_Common/design/design_store"; +import { announceDesignStore, draftProjectId } from "../A00_Common/design/design_store"; /** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */ const NOT_EDIT = @@ -213,6 +213,7 @@ export function createProfileHistory( try { onRestore(); hooks.forEach((entry) => entry.restore?.(kind)); + announceDesignStore(projectId); // 가게 듣는 쪽(관 · 구조물 사본)도 되돌린 값으로(472-5) // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. stack[cursor] = takeSnapshot(projectId, stack[cursor]); } finally { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index bde0bd1e8..c25586145 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -130,6 +130,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }; // 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은 // 한 벌이 보기에 따라 그린다(횡단 보기에서만). + /** 가게 관 · 구조물 초안 중 이 판이 마지막으로 읽거나 쓴 값(JSON) — 다르면 남(되돌리기 · 종단 보기 · + * 저장 뒤)이 고친 것이라 다시 실음(472-5 · 옵션만 바뀌어도). */ + let seen = { structures: "", pipes: "" }; const section = sharedStructures(deps.shared); bindDesignView(deps.shared, "cross", { structures: { @@ -137,6 +140,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc structures = withLocalIds(next); section.setStructures(structures); pushMarks(); + seen.structures = JSON.stringify(structures); // 제 쓰기 — 듣는 쪽이 남 고침과 가름(472-5) if (deps.projectId) writePendingStructures(deps.projectId, structures); // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) const picked = readSelection(deps.projectId); @@ -317,6 +321,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc deps.shared.lane.setStored(storedStructures); storedPipes = pipeResponse.pipe_points; structures = readPendingStructures(projectId) ?? stored.structures; + seen = { + structures: JSON.stringify(readPendingStructures(projectId)), + pipes: JSON.stringify(readPendingPipes(projectId)), + }; const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2 if (note) showToast(note); section.setStructures(structures); @@ -411,19 +419,18 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc */ function persistPipes(): void { if (!deps.projectId) return; - writePendingPipes( - deps.projectId, - pipeFacilities.map((pipe) => ({ - ...(pipe.id ? { id: pipe.id } : {}), // B05 초안과 같은 꼴(445 H2) - chainage_m: pipe.chainage_m, - source: pipe.source ?? "user", - facility: pipe.facility, - ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), - ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), - ...(pipe.options ? { options: pipe.options } : {}), - ...linkFields(pipe), - })), - ); + const next = pipeFacilities.map((pipe) => ({ + ...(pipe.id ? { id: pipe.id } : {}), // B05 초안과 같은 꼴(445 H2) + chainage_m: pipe.chainage_m, + source: pipe.source ?? "user", + facility: pipe.facility, + ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), + ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), + ...(pipe.options ? { options: pipe.options } : {}), + ...linkFields(pipe), + })); + seen.pipes = JSON.stringify(next); // 제 쓰기(472-5) + writePendingPipes(deps.projectId, next); deps.onPipesWritten?.(); } @@ -493,6 +500,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc root: section.root, listRoot: section.listRoot, load, + foreign: () => + !!deps.projectId && + (JSON.stringify(readPendingStructures(deps.projectId)) !== seen.structures || + JSON.stringify(readPendingPipes(deps.projectId)) !== seen.pipes), listStale: () => { if (!deps.projectId) return false; const shopStructures = readPendingStructures(deps.projectId) ?? storedStructures; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts index 1908219c9..bc0d0b315 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts @@ -55,6 +55,8 @@ export interface B06StructuresPanel { load: () => Promise; /** 가게(초안 · 없으면 저장분) 관 · 구조물 자리가 목록과 다른가 — 다시 실을 때(노선 초안 따라감 · 평면 마커 옮김 · 440). */ listStale: () => boolean; + /** 가게 초안이 이 판이 마지막으로 읽거나 쓴 값과 다른가(옵션만 바뀐 것도 · 472-5). */ + foreign: () => boolean; /** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인 * 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */ overrideOptions: (chainageM: number, patch: Record) => void; diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 136eb2ae7..70558d98d 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -29,6 +29,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 자동저장(디바운스 PUT) 없음 · 화면 잠금 = [저장] · [확정] · [초기화] · 추천 [적용]만 | `design_save.ts:249` | — | | 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` | | B05 · B06 캐시 = 한 벌 · [저장] 한 번에 함께 나감 | `design_shared.ts:395-448` · `design_save.ts:237` | — | +| 관 · 구조물 주인 = 가게 칸 하나 — 화면 사본은 가게 알림으로 맞춤(제 쓰기는 같은 값 · 남 고침은 다시 실음) | `design_view_profile.ts` 가게 구독 · `design_view_cross.ts` onShop · `B06_Section_UI_Page_Structures_Panel.ts` seen | `test_472_5_shop_owner` | | 정본 자리 = 값을 낳은 주인 — 노선 시설 = `pipe_points.json` · `structures.json` · 횡단 값 = 횡단 패치 | `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B05_Profile_Structures_Router.py:386-405` · `B06_Section_Section_Store.ts:449` | — | ## 바꿀 때 할 일 @@ -51,7 +52,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | [저장] 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장 | `design_save.ts:253-299` | 472-6 | | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | | 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 | -| ↶ ↷ 가 세션을 직접 되씀 — B06 조정 메모리 맵 · B05 상단측 맵이 옛 값일 수 있음 | `design_store.ts:231-235` | 추정 | +| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 | | 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 | | `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 | | 「[]」 — 되돌리기 = 값 · 떠남 확인 = 빈 칸 | `B05_Profile_UI_Profile_History.ts:50` · `design_leave.ts:41-42` | 코드 확인 | diff --git a/docs/wiki/concepts/design_page/values.md b/docs/wiki/concepts/design_page/values.md index bd1e19324..bd3cf30b2 100644 --- a/docs/wiki/concepts/design_page/values.md +++ b/docs/wiki/concepts/design_page/values.md @@ -24,8 +24,8 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 횡단 설계 결과(설계선) | DB `cross_sections.design` | 상세 cache(살아 있는 객체 · 제자리 고침) · 세션 `section-detail`(서버 사본만) · canon · 묶음 cross · `pipelineDetail` | TS `common_util_cross_design.ts:267` ↔ 파이썬 `B06_Section_Engine_Design.py:195` · `test_b06_cross_design_mirror` | 다시 셈마다 cache 제자리 · 입력 물러섬 = 저장 정본(`B06_Section_Cross_Refresh.ts:402-408`) · 페이지 수명 · [저장]·[확정]·[초기화] 뒤 비움 | | 측점 면적 | DB 면적 — 표준 측점 = 파이썬 · 구조물 측점 = Node | cache 면적 · 묶음 cross | TS `common_util_cross_design_areas.ts` ↔ 파이썬 `B06_Section_Engine_Areas.py` · 구조물 = `B06_Section_Structure_Layouts.ts`(Node 한 벌) | [저장] 때 서버가 브라우저 면적 버리고 다시 셈 | | 누가토량 | 종단 파일 `mass_haul` | 그릴 때 셈 · 배지 · `_derived/haul` · `B05_Profile_Api_HaulPlan.ts:28` kept | TS `common_util_mass_haul.ts:426` 한 벌(브라우저 · Node) | 그릴 때(접혀도 셈) · [저장] | -| 구조물 | `structures.json` | `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175` · 시설 줄 `design_facility_lane.ts:97` · savedStructures · `design_page_data.ts:25` · 세션 `structures` · `structure-type-cache` · cache | 따라감 TS `common_util_route_follow.ts:291` ↔ 파이썬 `common_util_route_follow.py:120` · `test_410_follow_xy` | 시설 줄 알림 · 숨은 보기는 보일 때 | -| 관 | `pipe_points.json` | 배수판 둘(`B05_Profile_UI_Drainage_Panel.ts:123 · 142`) · `B06_Section_UI_Page_Structures_Panel.ts:176` · 시설 줄 `:101` · `design_view_profile.ts:116` · savedPipes · `design_pipe_ids.ts:24` · 세션 `pipes` · `pipe-ids` · `culvertopt` · `kindreset` · `pipe-option-queue` · cache culvert · 유역 pkl | 관 길이 TS `design_structure_pipe_calc.ts` · `B06_Section_Structure_Layouts.ts:95`(Node 한 벌) · `test_b06_pipe_length` | 시설 줄 알림 · 배수 다시 나눔 응답 · [저장] 뒤 다시 받음 | +| 구조물 | `structures.json` | `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175` · 시설 줄 `design_facility_lane.ts:97` · savedStructures · `design_page_data.ts:25` · 세션 `structures` · `structure-type-cache` · cache | 따라감 TS `common_util_route_follow.ts:291` ↔ 파이썬 `common_util_route_follow.py:120` · `test_410_follow_xy` | 주인 = 가게 `structures`(세션) · 사본은 화면 사본 — 가게가 바뀌면(되돌리기 포함 · `announceDesignStore`) 종단 목록 `takeStructures`(보일 때) · 횡단 판 `foreign()` → 다시 실음 · 숨은 보기는 보일 때 load | +| 관 | `pipe_points.json` | 배수판 둘(`B05_Profile_UI_Drainage_Panel.ts:123 · 142`) · `B06_Section_UI_Page_Structures_Panel.ts:176` · 시설 줄 `:101` · `design_view_profile.ts:116` · savedPipes · `design_pipe_ids.ts:24` · 세션 `pipes` · `pipe-ids` · `culvertopt` · `kindreset` · `pipe-option-queue` · cache culvert · 유역 pkl | 관 길이 TS `design_structure_pipe_calc.ts` · `B06_Section_Structure_Layouts.ts:95`(Node 한 벌) · `test_b06_pipe_length` | 주인 = 가게 `pipes` · 배수 편집기는 가게가 바뀌면 숨어도 `adoptPipes`(제 쓰기 메아리 빼고) · 횡단 판 `foreign()` → 다시 실음 · 고름 · 알약 관 = 시설 줄(`shownPipes`) · 배수 다시 나눔 응답 · [저장] 뒤 다시 받음 | | 추가 측점 | `plus_stations.json` | `B05_Profile_Api_PlusStations.ts:14 · 74 · 76` · 세션 `plus-stations` | 따라감 TS `followPlusDraft` ↔ 파이썬 `B05_Profile_Plus_Stations.py:82` · `test_459_route_seat_mirror` | 노선 초안 follow 뒤 · [저장] | | 소단 구간 · 깊이 | 구간 = `structures.json` · 깊이 = DB 패치 | 세션 `berm`(시설 줄만 씀) · `bermdepth` · cache `design.berm` · 초안 노선 `bermAt`(`design_shared.ts:248`) | TS `B06_Section_UI_Page_Persist.ts:157` bermSpansFromStructures ↔ 파이썬 `B06_Section_Engine_Structures_Wall.py:124` load_berm_spans · `test_272_berm_source` | 구조물 바뀜 → 시설 줄 `syncBerm`(같으면 안 씀) → cross 한 번 · 사본 없으면 저장분 | | 페이지 설정 | DB 단계 2 설정 · 종단 정책 | ★ 가게 `pageSettings`(세션 `pagesettings`) — 폼 · 종단도 기울기 한도 · 시작 측점이 듣고 최소고 강제는 바로 읽음 | 정책 파이썬 기준표 ↔ TS `B05_Profile_UI_Panel_Fields.ts` · `test_442_2_grade_limit_mirror` | 열 때 두 보기가 얹음 · [저장] 설정 차례 | diff --git a/resources/tester/helper_472_5_shop.mjs b/resources/tester/helper_472_5_shop.mjs new file mode 100644 index 000000000..1f04eae81 --- /dev/null +++ b/resources/tester/helper_472_5_shop.mjs @@ -0,0 +1,93 @@ +/* 472-5 헬퍼 — node helper_472_5_shop.mjs <묶음.mjs> · 표준출력 JSON. + * ① 되돌리기 · 다시하기가 가게 듣는 쪽(관 · 구조물 사본)을 부르는가 ② 배수 편집기가 남이 고친 관 초안을 받는가. */ +import { pathToFileURL } from "node:url"; + +const storage = () => { + 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(), + }; +}; +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.window = { sessionStorage: storage(), localStorage: storage() }; // 손길 듣기 없음 = 열 때 규칙 꺼짐 +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.document = any; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p472five"; +localStorage.setItem("frd_current_project_id", P); +m.pinDraftProject(P); +const store = m.useDesignStore(P, null); +const A = [{ structure_id: "a", type_id: "berm", placement: "point", chainage_m: 20, options: {} }]; +const AB = [ + ...A, + { structure_id: "b", type_id: "berm", placement: "point", chainage_m: 40, options: {} }, +]; +const pipes1 = [ + { id: "k1", chainage_m: 100, source: "user", facility: "pipe", options: { diameter_mm: 800 } }, +]; +const pipes2 = [ + { id: "k1", chainage_m: 100, source: "user", facility: "pipe", options: { diameter_mm: 1000 } }, +]; +store.set("structures", A); +store.set("pipes", pipes1); +const history = m.createProfileHistory(() => undefined, 100); +store.set("structures", AB); +store.set("pipes", pipes2); +history.record(); +const heard = { structures: [], pipes: [] }; +store.subscribe("structures", (v) => heard.structures.push(v)); +store.subscribe("pipes", (v) => heard.pipes.push(v)); +history.undo(); +const afterUndo = { + structures: heard.structures.at(-1) ?? null, + pipes: heard.pipes.at(-1) ?? null, +}; +history.redo(); +const afterRedo = { + structures: heard.structures.at(-1) ?? null, + pipes: heard.pipes.at(-1) ?? null, +}; + +// ② 배수 편집기 — 같은 몸통 = 메아리(안 받음) · 옵션만 바뀜 = 받음 · 고른 관 유지 +const editor = m.createPipeEditor(() => undefined); +const facilities = m.createFacilityStore(); +editor.setPipes([ + { chainage_m: 100, reason: "user", id: "k1" }, + { chainage_m: 140, reason: "user", id: "k2" }, +]); +facilities.replaceFromResponse(pipes1); +editor.select(0); +const body = () => + facilities.attach( + editor.pipes().map((p) => ({ chainage_m: p.chainage_m, source: p.reason, id: p.id })), + ); +const echo = m.adoptEditorPipes(editor, facilities, body(), body()); +const next = [ + { id: "k2", chainage_m: 140, source: "user", facility: "pipe" }, + { id: "k1", chainage_m: 100, source: "user", facility: "pipe", options: { diameter_mm: 1200 } }, +]; +const adopted = m.adoptEditorPipes(editor, facilities, body(), next); +const picked = editor.pipes()[editor.selected() ?? -1]?.id ?? null; +console.log( + JSON.stringify({ + afterUndo, + afterRedo, + echo, + adopted, + picked, + options: facilities.get(100)?.options ?? null, + }), +); diff --git a/resources/tester/helper_472_5_shop_entry.ts b/resources/tester/helper_472_5_shop_entry.ts new file mode 100644 index 000000000..f830f139a --- /dev/null +++ b/resources/tester/helper_472_5_shop_entry.ts @@ -0,0 +1,6 @@ +/* 472-5 헬퍼 — 되돌리기 · 가게 알림 · 배수 편집기 받기(vite ssr) · test_472_5_shop_owner.py 가 부름. */ +export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History"; +export { pinDraftProject, useDesignStore } from "../../A00_Common/design/design_store"; +export { adoptEditorPipes } from "../../B05_Profile/B05_Profile_UI_Drainage_Parts"; +export { createPipeEditor } from "../../B05_Profile/B05_Profile_UI_Drainage_Pipes"; +export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility"; diff --git a/resources/tester/test_188_1_last_pick.py b/resources/tester/test_188_1_last_pick.py index e4c80632c..c5b66376f 100644 --- a/resources/tester/test_188_1_last_pick.py +++ b/resources/tester/test_188_1_last_pick.py @@ -101,5 +101,6 @@ def test_종단_보기_배선() -> None: page = _read("A00_Common", "design", "design_view_profile.ts") assert "regularChainage:" in page assert "remember: (chainageM) => rememberPick(chainageM, null)" in page - assert "graphTargetAt(id, chainageM, lastPipes ?? [], sections)" in page + # 472-5 — 관 = 시설 줄(가게 초안 자리 · 노선 따라감 뒤도) · 옛 알림 자리 안 봄 + assert "graphTargetAt(id, chainageM, shownPipes(), sections)" in page assert "const sections = structurePick ? [] :" in page diff --git a/resources/tester/test_472_5_shop_owner.py b/resources/tester/test_472_5_shop_owner.py new file mode 100644 index 000000000..02ac90f81 --- /dev/null +++ b/resources/tester/test_472_5_shop_owner.py @@ -0,0 +1,109 @@ +"""PLAN 472-5 5-A (요구 273) — 관 · 구조물 값 주인 하나(설계 가게) · 사본 갈림 막음. + +못박는 것 + - 되돌리기 · 다시하기(세션 직접 되씀)가 가게 듣는 쪽을 되돌린 값으로 부름 — 옛 코드는 안 불러 + 종단 목록 · 횡단 판 · 배수 편집기가 옛 값을 쥐고 다음 조작이 되돌림을 덮었음(옛 코드 실패). + - 배수 편집기 받기 — 제 쓰기 메아리는 안 받음 · 남이 고친 옵션 · 차례도 받음 · 고른 관(id) 유지. + - 배선 — 종단 보기 = 관(숨어도) · 구조물(보일 때) 구독 · 횡단 보기 = 남 고침(옵션만도) 다시 실음 · + 횡단 판은 쓰기 전에 제 값을 적음(메아리 가름). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_5_shop_owner.py -q +""" + +from __future__ import annotations + +import json +import shutil +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" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("s4725") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_5_shop_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + check=True, + capture_output=True, + timeout=300, + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_5_shop_entry.js", bundle) + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_472_5_shop.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_되돌리기가_가게_듣는_쪽을_부름(out) -> None: + assert [s["structure_id"] for s in out["afterUndo"]["structures"]] == ["a"] + assert out["afterUndo"]["pipes"][0]["options"] == {"diameter_mm": 800} + assert [s["structure_id"] for s in out["afterRedo"]["structures"]] == ["a", "b"] + assert out["afterRedo"]["pipes"][0]["options"] == {"diameter_mm": 1000} + + +def test_배수_편집기_남_고침만_받음(out) -> None: + assert out["echo"] is False + assert out["adopted"] is True + assert out["options"] == {"diameter_mm": 1200} + assert out["picked"] == "k1" + + +def test_배선() -> None: + profile = _read("A00_Common", "design", "design_view_profile.ts") + assert ( + 'designStore.subscribe("pipes", (next) => next && profilePanel.drainage.adoptPipes(next))' + in profile + ) + assert ( + 'designStore.subscribe("structures", (next) => host.visible() && ' + "bridge.takeStructures(next))" in profile + ) + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "structuresPanel.foreign() && reloadPanel()" in cross + assert ( + 'shop.subscribe("pipes", onShop)' in cross + and 'shop.subscribe("structures", onShop)' in cross + ) + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert panel.index("seen.pipes = JSON.stringify(next);") < panel.index( + "writePendingPipes(deps.projectId, next);" + ) + assert panel.index("seen.structures = JSON.stringify(structures);") < panel.index( + "writePendingStructures(deps.projectId, structures);" + ) + history = _read("B05_Profile", "B05_Profile_UI_Profile_History.ts") + assert "announceDesignStore(projectId);" in history