From 529baca3ff7f9c8e5e2e76dc9c2c0510e1df4441 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 18:21:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C):=203?= =?UTF-8?q?23-6=20=EB=B0=94=EA=BF=88=20=ED=95=9C=20=EA=B8=B8=20=E2=80=94?= =?UTF-8?q?=20=EB=B0=94=EB=80=9C=20=EC=8B=A0=ED=98=B8=20=ED=95=98=EB=82=98?= =?UTF-8?q?(=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=B4=88=EC=95=88=EB=8F=84=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EA=B0=80=EA=B2=8C=20=C2=B7=20watchDrafts)?= =?UTF-8?q?=20=C2=B7=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20=ED=91=9C?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99(swapsInPlace=20=C2=B7=20E=20=C2=B7=20F=20?= =?UTF-8?q?=EC=A0=90=ED=98=95=20=E2=86=94=20=EA=B5=AC=EA=B0=84=ED=98=95=20?= =?UTF-8?q?=3D=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90=EC=97=90=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C)=20=C2=B7=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=20=EC=97=86=EC=95=B0(=EC=95=8C=EB=A6=BC)=20?= =?UTF-8?q?=C2=B7=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=EA=B0=92=20=EC=95=88=20=EB=A7=8C=EB=93=A6=20=C2=B7=20?= =?UTF-8?q?replayDraftPipeOptions=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 초안 쓰기 = useDesignStore().set("structures")(칸 그대로) → 관 초안과 같은 구독 · 횡단 보기 watchDrafts(구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만 · 옵션만은 폼 길이 그 카드만 · 카드 줌 · 팬 지킴) · 옛 되부름 onStructuresChanged 걷음 - 종류 바꿈 = 표(catalog) swapsInPlace — 정본 다름 · 구조물 자리꼴 다름(E · F) = 지우고 같은 기준 측점에 새로(구간 = 기본 길이) · 같으면 종류 키만 · 막히면 옛 기록 · 옛 종류 폼 + 알림(전엔 [수정] 조기 반환 → 조용히 되돌림 · 드롭다운만 새 종류) - syncApplied = 사용자가 조작한 세션이 있을 때만 깎음(카드 그리기가 자동 배치를 조정값으로 굳혀 [저장]으로 내보내던 것) - replayDraftPipeOptions 걷음 — 그림 자료는 형상 얹기(초안 현황) · 다단 조정값은 조정 세션이 정본 - 시험: test_323_6_change(신호 · 거르기 · 표 규칙 · 그리기 · 걷음) · test_319 점형 ↔ 구간형 · 막힘 알림 · 되얹기 시험 = 형상 얹기로 · 옛 기대 글 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_structure_status.ts | 33 ++++++++ A00_Common/design/design_view_cross.ts | 15 ++-- B05_Profile/B05_Profile_Api_Structures.ts | 7 +- .../B05_Profile_UI_Structures_Panel_Events.ts | 18 ++-- .../B06_Section_UI_Page_Pipe_Options.ts | 20 ----- .../B06_Section_UI_Page_Station_Controls.ts | 7 +- .../B06_Section_UI_Page_Structures_Panel.ts | 15 +--- common_util/common_util_structure_catalog.ts | 14 +++- resources/tester/helper_319_kind_swap.cjs | 68 ++++++++++++++- resources/tester/helper_323_6_change.cjs | 82 +++++++++++++++++++ .../tester/helper_b06_draft_pipe_replay.cjs | 52 ++++++------ resources/tester/test_298_add_reanalyze.py | 10 ++- resources/tester/test_313_clear_all.py | 2 +- resources/tester/test_319_kind_swap.py | 16 ++++ resources/tester/test_323_6_change.py | 79 ++++++++++++++++++ .../tester/test_b06_draft_pipe_replay.py | 10 ++- 16 files changed, 352 insertions(+), 96 deletions(-) create mode 100644 resources/tester/helper_323_6_change.cjs create mode 100644 resources/tester/test_323_6_change.py diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index 1fc2a3709..79203c190 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -9,6 +9,7 @@ import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; import type { PlannedStation } from "../../common_util/common_util_structure_station"; +import { useDesignStore, type DesignStoreValues } from "./design_store"; /** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */ export function structureStatus( @@ -42,3 +43,35 @@ export function plannedIrregular( }; }); } + +/** + * 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 · + * 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴). + * 돌려주는 함수로 끊음. + */ +export function watchDrafts(projectId: string | null, onChange: () => void): () => void { + const shop = useDesignStore(projectId, null); + type Pipe = NonNullable[number]; + const layout = (pipes: ReadonlyArray | null): string => + JSON.stringify( + (pipes ?? []).map((p) => [ + p.chainage_m, + p.facility ?? "pipe", + p.start_m ?? null, + p.end_m ?? null, + p.summary_item ?? null, + p.group_items ?? null, + ]), + ); + let seen = layout(shop.get("pipes")); + const offs = [ + shop.subscribe("structures", onChange), + shop.subscribe("pipes", (pipes) => { + const key = layout(pipes); + if (key === seen) return; + seen = key; + onChange(); + }), + ]; + return () => offs.forEach((off) => off()); +} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 0dae09d4a..dba4f7730 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -37,14 +37,13 @@ import { import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { paintMapPick } from "./design_pick_profile"; -import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { watchDrafts } from "./design_structure_status"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, - replayDraftPipeOptions, type PipeOptionsContext, } from "../../B06_Section/B06_Section_UI_Page_Pipe_Options"; import { @@ -145,7 +144,6 @@ export async function mountCrossView( syncBermSpans(structures); }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). - onStructuresChanged: () => void refreshDetailForStructures(), onPipesWritten: () => { window.clearTimeout(pipeReloadTimer); pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200); @@ -565,11 +563,10 @@ export async function mountCrossView( void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) - host.onDispose( - onStaleStructureTypes(() => { - if (host.visible()) void structuresPanel.load().then(restoreStructurePick); - }), - ); + const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick); + host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel())); + // 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음) + host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); window.clearTimeout(wallAdjustTimer); @@ -669,8 +666,6 @@ export async function mountCrossView( bindPipeOptions(shared, (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), ); - // 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a). - replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId)); renderSectionDetail(); // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 8f1e945cd..ea2106c23 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -11,11 +11,11 @@ * 하려는 것이라, 목록은 반드시 서버에서 받아 온다. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import { useCatalogDefinition } from "../common_util/common_util_structure_catalog"; import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { useDesignStore } from "../A00_Common/design/design_store"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { type DefinedRule, @@ -362,11 +362,12 @@ export function defaultOptions(type: StructureType): Record("structures", projectId); + return useDesignStore(projectId, null).get("structures"); } +/** 설계 가게로 씀 — 관 초안과 같은 바뀜 신호(구독 · 323-6). 칸은 그대로(`structures`). */ export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void { - writeState("structures", next, projectId); + useDesignStore(projectId, null).set("structures", next); } /** diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 77aebce06..4b0209d90 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -18,7 +18,8 @@ import { asRoad } from "./B05_Profile_UI_Structures_Define"; import { readState, writeState } from "../A00_Common/b_page_state"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection"; import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item"; -import { showConfirmDialog } from "@ui/ui_template_elements"; +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { swapsInPlace } from "../common_util/common_util_structure_catalog"; import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ @@ -89,9 +90,10 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { : ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M); const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? "")); if (!next || !prev) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기 - // 정본이 다른 종류(관 지점 ↔ 구조물 — 맹암거 · 개거 등)로 = 옛 것을 지워 「없음」을 거친 뒤 같은 - // 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 기본값 · 요구 217) · 옛 조정값은 318-4 대로 버림 - if (!!next.managed_by !== !!prev.managed_by) { + // 표 규칙(323-6 · `swapsInPlace`) — 정본이 다르거나(관 지점 ↔ 구조물) 구조물 자리꼴이 다르면(E · F 점형 ↔ + // 구간형) 옛 것을 지워 「없음」을 거친 뒤 같은 기준 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 + // 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림 + if (!swapsInPlace(prev.type_id, next.type_id)) { const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; if (record) { ctx.structures.splice(index, 1); @@ -135,8 +137,12 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { // 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4) if (pipeItem) forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id); - // 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림 - if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record; + // 반영이 막히면(측점 · 필수 칸 등) 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰 · 323-6) + if (record && draft && ctx.structures[index] === draft) { + ctx.structures[index] = record; + ctx.loadForm(record); + showToast(`${next.name}(으)로 못 바꿈 — 폼 칸(측점 · 필수 칸)을 확인하세요.`, "error"); + } } type TypeValues = { diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 48b66ddc8..c39fc5dbc 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -7,7 +7,6 @@ * ========================================================================== */ import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets"; -import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; @@ -276,22 +275,3 @@ function applySoilTiers( if (Object.keys(clipped).length) queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped)); } - -/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */ -const ADJUST_OWNED = /_revet_(height_m|form)$/; - -/** - * 관 초안 옵션을 **횡단 캐시에 다시 얹는다**(24-7a). 새로고침 뒤 캐시 상세는 저장분이라 - * 저장 전에 고친 월류 폭·관경 등이 폼·카드에서 옛 값으로 돌아간다 — 초안이 관 옵션의 정본이다. - */ -export function replayDraftPipeOptions( - ctx: PipeOptionsContext, - pipes: ReadonlyArray | null, -): void { - for (const pipe of pipes ?? []) { - const patch = Object.fromEntries( - Object.entries(pipe.options ?? {}).filter(([key]) => !ADJUST_OWNED.test(key)), - ); - if (Object.keys(patch).length) applyPipeOptionsToCache(ctx, pipe.chainage_m, patch, false); - } -} diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 390a81de5..7a6ebe3c7 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -280,10 +280,11 @@ export function createStationControls(deps: StationControlDeps): StationControls }, shapePicked: () => revetSelection?.shape === true, syncApplied: (chainageM, role, applied) => { - // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. + // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. 그리기는 조정값을 + // 새로 만들지 않음(323-6) — 사용자가 조작한 세션이 있을 때만 그 값을 깎음(없으면 자동 그대로 · 저장 안 함) const key = revetKey(chainageM, role); - const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST }; - if (sameAdjust(stored, applied)) return; + const stored = revetShifts.get(key); + if (!stored || sameAdjust(stored, applied)) return; storeAdjust(key, { ...applied }); }, update: (chainageM, role, patch) => { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index ea213c922..435cc2c02 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -108,9 +108,6 @@ export interface B06StructuresPanelDeps { /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => void; - /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** - * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ - onStructuresChanged?: () => void; /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ onPipesWritten?: () => void; } @@ -215,7 +212,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setStructures(structures); pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); - deps.onStructuresChanged?.(); // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) const picked = readSelection(deps.projectId); const moved = picked?.structureId @@ -284,7 +280,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); showToast(PIPE_ADD_NOTICE, "success"); }, // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 @@ -305,7 +300,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (mine && JSON.stringify(linkFields(mine)) !== before) { persistPipes(); section.setPipeFacilities(pipeFacilities); - deps.onStructuresChanged?.(); } } // 폼 값은 **폼 칸 전부**다 — 비운 칸은 지운 것(계획서 24-4a · B05 [저장]과 같은 규칙). @@ -333,10 +327,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } persistPipes(); section.setPipeFacilities(pipeFacilities); - if (facility !== was) { - pushMarks(); - deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`) - } + // 카드 · 그림 다시 = 초안 구독(323-6 · `watchDrafts` — 종류가 바뀌면 그 관 자리 그림이 새 종류) + if (facility !== was) pushMarks(); }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { @@ -517,7 +509,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (plan.pipes.length) { pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); persistPipes(); - deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로 } snapNotice(plan.pipes.length, plan.skipped); } @@ -544,7 +535,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2) showToast(PIPE_MOVE_NOTICE, "success"); } @@ -563,7 +553,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); showToast(PIPE_REMOVE_NOTICE, "success"); } diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index 9c0dfccd3..d416affff 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -170,11 +170,23 @@ export function cardTitle( return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`; } -/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ /** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */ export const fieldName = (key: string): string | undefined => (PARTS as unknown as { "칸 이름"?: Record })["칸 이름"]?.[key]; +/** + * 종류 바꿈 = 제자리(종류 키만 갈아 끼움)인가(323-6 · 표 규칙) — 정본이 같고, 구조물은 자리꼴도 같아야. + * 아니면(관 지점 ↔ 구조물 · E · F 점형 ↔ 구간형) 옛 것을 지우고 같은 기준 측점에 새로(구간 = 기본 길이). + * 관 지점 시설은 구간도 옵션(길이 · 전 · 후)이라 자리꼴로 안 가름. + */ +export function swapsInPlace(fromTypeId: string, toTypeId: string): boolean { + const from = catalogRow(fromTypeId); + const to = catalogRow(toTypeId); + if (!from || !to || from.owner !== to.owner) return false; + return from.owner === "pipe_points" || from.placement === to.placement; +} + +/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ export function shapeNeeds(typeId: string): readonly string[] { const shape = catalogRow(typeId)?.shape ?? "none"; const box = (PARTS as unknown as Parts).parts.형상 as Record; diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index d59153035..4242bf606 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -95,10 +95,14 @@ const out = {}; ], { "./B05_Profile_UI_Structures_Define": { asRoad: (record) => record }, - "@ui/ui_template_elements": { showConfirmDialog: async () => true, showToast: () => {} }, + "@ui/ui_template_elements": { + showConfirmDialog: async () => true, + showToast: (text, kind) => toasts.push([text, kind ?? null]), + }, "./B06_Section_Section_Drafts": { forgetPreviewAdjusts: () => {} }, }, ); + const toasts = []; const { bindStructuresEvents } = load(B05("B05_Profile_UI_Structures_Panel_Events")); const typeMap = new Map(TYPES.map((type) => [type.type_id, type])); const el = () => ({ @@ -109,8 +113,18 @@ const out = {}; }, }); const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } }); - const run = ({ structures = [], pipes = [], editing = null, pipeAt = null, next }) => { + // commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림) + const run = ({ + structures = [], + pipes = [], + editing = null, + pipeAt = null, + next, + commitOk = true, + }) => { + toasts.length = 0; const calls = { + loaded: [], removed: [], addAt: [], emit: 0, @@ -164,14 +178,19 @@ const out = {}; syncPlacementFields: () => {}, renderOptionFields: () => {}, syncFacilityForm: () => {}, - liveCommit: () => (calls.live += 1), + liveCommit: () => { + calls.live += 1; + const at = structures.findIndex((s) => s.structure_id === editingId); + if (commitOk && at >= 0) structures[at] = { ...structures[at] }; + }, + loadForm: (target) => calls.loaded.push(target?.type_id ?? null), emit: () => (calls.emit += 1), addAt: (at, typeId) => calls.addAt.push([at, typeId]), interval: () => 20, }; bindStructuresEvents(ctx); ctx.typeSelect.listeners.change(); - return { ...calls, left: structures.map((s) => s.type_id) }; + return { ...calls, left: structures.map((s) => s.type_id), toasts: [...toasts] }; }; const store = window.sessionStorage; const kindreset = () => JSON.parse(store.getItem("aislo:draft:kindreset:P") ?? "null"); @@ -219,6 +238,47 @@ const out = {}; next: "open_ditch", }); out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null }); + // 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리 + out.point_to_interval = run({ + structures: [{ structure_id: "m1", type_id: "mirror", placement: "point", chainage_m: 140 }], + editing: "m1", + next: "guardrail", + }); + out.interval_to_point = run({ + structures: [ + { + structure_id: "g1", + type_id: "guardrail", + placement: "interval", + start_m: 100, + chainage_m: 105, + end_m: 120, + }, + ], + editing: "g1", + next: "mirror", + }); + out.interval_same = run({ + structures: [ + { + structure_id: "r1", + type_id: "refuge", + placement: "interval", + start_m: 100, + chainage_m: 105, + end_m: 120, + }, + ], + editing: "r1", + next: "work_yard", + }); + // 막힘 = 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰) + out.blocked = run({ + structures: [{ structure_id: "b1", type_id: "mirror", placement: "point", chainage_m: 30 }], + editing: "b1", + next: "curb", + commitOk: false, + }); } // ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ────── diff --git a/resources/tester/helper_323_6_change.cjs b/resources/tester/helper_323_6_change.cjs new file mode 100644 index 000000000..df09a044a --- /dev/null +++ b/resources/tester/helper_323_6_change.cjs @@ -0,0 +1,82 @@ +/* PLAN 323-6 바꿈 한 길 헬퍼 — 진짜 `B05_Profile_Api_Structures.ts`(구조물 초안) · `design_store.ts`(바뀜 신호)를 + * 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON. 짝 = `test_323_6_change.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const memory = new Map(); +const store = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + key: (i) => [...memory.keys()][i] ?? null, + get length() { + return memory.size; + }, +}; +globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} }; +globalThis.sessionStorage = store; +globalThis.localStorage = store; + +const cache = new Map(); +function 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 need = (spec) => { + if (CATALOG.isCatalogRequest(spec)) return CATALOG; + if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + if (spec.startsWith("@config/")) return load(path.join(ROOT, "config", `${spec.slice(8)}.ts`)); + if (spec.startsWith(".")) return load(path.join(path.dirname(full), `${spec}.ts`)); + return require(spec); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts")); +const { useDesignStore } = load(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const heard = { structures: [], pipes: [] }; +const shop = useDesignStore("P", null); +for (const key of ["structures", "pipes"]) shop.subscribe(key, (value) => heard[key].push(value)); +const wall = { structure_id: "w1", type_id: "retaining_wall", chainage_m: 10 }; +api.writePendingStructures("P", [wall]); +api.writePendingStructures("P", null); +api.writePendingStructures("P", [wall]); +// 바뀜 신호 거르기 — 구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만(옵션만 = 폼 길이 그 카드) +const status = load(path.join(ROOT, "A00_Common", "design", "design_structure_status.ts")); +let fired = 0; +const off = status.watchDrafts("Q", () => (fired += 1)); +const shopQ = useDesignStore("Q", null); +shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 1 } }]); +shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 2 } }]); +shopQ.set("pipes", [{ chainage_m: 12, source: "user", options: { a: 2 } }]); +shopQ.set("pipes", [{ chainage_m: 12, source: "user", facility: "box_culvert" }]); +shopQ.set("structures", []); +off(); +shopQ.set("structures", null); + +process.stdout.write( + JSON.stringify({ + watch: fired, + heard: heard.structures.map((v) => (v ? v.length : null)), + pipesHeard: heard.pipes.length, + read: api.readPendingStructures("P"), + key: memory.has("aislo:draft:structures:P"), + swaps: [ + "mirror>curb", + "mirror>guardrail", + "refuge>work_yard", + "pipe>box_culvert", + "pipe>revetment", + "cross_underdrain>pipe", + ].map((pair) => [pair, CATALOG.swapsInPlace(...pair.split(">"))]), + }), +); diff --git a/resources/tester/helper_b06_draft_pipe_replay.cjs b/resources/tester/helper_b06_draft_pipe_replay.cjs index 50718cd1b..df2316c72 100644 --- a/resources/tester/helper_b06_draft_pipe_replay.cjs +++ b/resources/tester/helper_b06_draft_pipe_replay.cjs @@ -1,5 +1,6 @@ -/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. - * 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다. +/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a → 323-6) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다 — 옛 되얹기 + * (`replayDraftPipeOptions`)는 걷고 형상 얹기 한 벌(`attachShapes` · 초안 현황)이 맡음(323-6). * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. * * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. @@ -47,50 +48,45 @@ function loadTs(file) { return box.exports; } -const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts")); +const DEF = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t })); +const attach = loadTs(path.join(ROOT, "common_util", "common_util_structure_attach.ts")); -// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 옹벽 없음 +// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 조정창 정본(기슭막이 조정값) +const adjust = { outlet: { x: 0.4, d: null, h: 2.0, m: null } }; const detail = { cross_sections: [ { chainage_m: 85.05, culvert: { pipe_kind: "파형강관", diameter_m: 1.0, inlet: {}, outlet: {} }, + design: { revet_adjust: adjust }, }, { chainage_m: 173.09, ford: { span_m: 10, diameter_m: 1.0, pipe_count: 1, wing_in: {}, wing_out: {} }, }, + { chainage_m: 258.12 }, ], }; -const sent = []; -const ctx = { - detail: () => detail, - ford: { - setPipe: (chainageM, patch) => { - sent.push({ chainageM, patch }); - if (patch.ford_width_m) detail.cross_sections[1].ford.span_m = patch.ford_width_m; - }, - setWing: () => {}, - }, - box: {}, - revetOffset: { update: () => sent.push({ revet: true }) }, - overrideOptions: () => {}, - refreshCard: () => {}, -}; -// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 되살리지 않음) -options.replayDraftPipeOptions(ctx, [ - { chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } }, - { chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } }, - { chainage_m: 258.12, options: {} }, -]); -options.replayDraftPipeOptions(ctx, null); +// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 조정값은 안 건드림) +attach.attachShapes( + detail.cross_sections, + [ + { chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } }, + { chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } }, + ], + [], + TYPES, +); process.stdout.write( JSON.stringify({ fordSpan: detail.cross_sections[1].ford.span_m, culvertDiameter: detail.cross_sections[0].culvert.diameter_m, - fordCalls: sent.filter((s) => s.chainageM === 173.09).length, - revetTouched: sent.some((s) => s.revet === true), + fordAt: detail.cross_sections.filter((s) => s.ford).map((s) => s.chainage_m), + revetTouched: detail.cross_sections[0].design.revet_adjust !== adjust, }), ); diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py index 0429a2dd3..8fcfe4b8b 100644 --- a/resources/tester/test_298_add_reanalyze.py +++ b/resources/tester/test_298_add_reanalyze.py @@ -65,8 +65,7 @@ def test_B06_추가_순간_기다림_적고_초안_씀(): body.index("pipeFacilities.push({"), body.index("if (waitsFordHeight(attributes.facility, attributes.options))"), body.index("fordWaitsOf(deps.projectId).add(at);"), - body.index("persistPipes();"), - body.index("deps.onStructuresChanged?.();"), + body.index("persistPipes();"), # 초안 씀 = 카드 다시(바뀜 신호 하나 · 323-6) ] assert order == sorted(order) @@ -145,8 +144,11 @@ def test_실패하면_토스트_한_줄(out): def test_추가_뒤_새_측점_횡단_미리_뜸(): body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:") - assert "deps.onStructuresChanged?.();" in body - assert "onStructuresChanged: () => void refreshDetailForStructures()," in CROSS + assert "persistPipes();" in body # 초안 씀 → 구독(`watchDrafts`)이 카드 다시(323-6) + # 카드 다시 = 바뀜 신호 하나(323-6 · 관 · 구조물 초안 구독 · 옛 되부름 갈음) + assert ( + "watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())" in CROSS + ) drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") stations = _body( drafts, "export async function withDraftStations(", "parked.set(detail, stale);" diff --git a/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py index 4e5285c56..dcc7f15cd 100644 --- a/resources/tester/test_313_clear_all.py +++ b/resources/tester/test_313_clear_all.py @@ -83,7 +83,7 @@ def test_B05_B06_같은_목록_각자_관빼기(): assert "onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage)," in b05 # 공용 확인창 · 저장 길(초안 → [저장])은 안 고침 events = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts") - assert 'import { showConfirmDialog } from "@ui/ui_template_elements";' in events + assert 'import { showConfirmDialog, showToast } from "@ui/ui_template_elements";' in events assert "ctx.emit();" in events diff --git a/resources/tester/test_319_kind_swap.py b/resources/tester/test_319_kind_swap.py index 903fd7334..dc9fbdee1 100644 --- a/resources/tester/test_319_kind_swap.py +++ b/resources/tester/test_319_kind_swap.py @@ -75,6 +75,22 @@ def test_same_owner_switch_unchanged(result: dict) -> None: assert result["to_blank"]["reset"] == 1 and result["to_blank"]["addAt"] == [] +def test_point_interval_switch_replaces_at_same_anchor(result: dict) -> None: + # 323-6 — 표 규칙(`swapsInPlace`) · 구간 = 기본 길이(`addAt`) + up = result["point_to_interval"] + assert up["left"] == [] and up["addAt"] == [[140, "guardrail"]] and up["live"] == 0 + down = result["interval_to_point"] + assert down["left"] == [] and down["addAt"] == [[105, "mirror"]] + same = result["interval_same"] + assert same["addAt"] == [] and same["live"] == 1 and same["left"] == ["work_yard"] + + +def test_blocked_switch_restores_with_notice(result: dict) -> None: + blocked = result["blocked"] + assert blocked["left"] == ["mirror"] and blocked["loaded"] == ["mirror"] + assert len(blocked["toasts"]) == 1 and blocked["toasts"][0][1] == "error" + + def test_grid_station_kind_change_uses_bare_copy(result: dict) -> None: # 일반 측점 자리 = 측점이 남음 — 저장분에서 구조물만 뺀 사본에 새 종류(지형 쪽 고친 값 그대로) grid = result["grid"] diff --git a/resources/tester/test_323_6_change.py b/resources/tester/test_323_6_change.py new file mode 100644 index 000000000..233ecc636 --- /dev/null +++ b/resources/tester/test_323_6_change.py @@ -0,0 +1,79 @@ +"""PLAN 323-6 바꿈 한 길 — 바뀜 신호 하나 · 종류 바꿈 표 규칙 · 그리기가 조정값을 안 만듦 · 옛 되얹기 걷음. + +- 구조물 초안도 설계 가게(`useDesignStore`)로 씀 = 관 초안과 같은 신호 · 칸은 그대로(`structures`) · + 횡단 보기가 두 초안을 구독해 카드 · 그림을 다시(옛 되부름 `onStructuresChanged` 갈음). +- 종류 바꿈 = 정본 같고 구조물은 자리꼴도 같아야 제자리(`swapsInPlace`) · 아니면 지우고 같은 기준 측점에 새로 + (흐름 = test_319_kind_swap). +- 카드 그리기의 적용값 되받기는 사용자가 조작한 세션이 있을 때만(자동 배치를 조정값으로 안 굳힘). +TS 쪽 = `helper_323_6_change.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_6_change.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def _src(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +def test_structure_draft_signals_through_store(out: dict) -> None: + assert out["heard"] == [1, None, 1] and out["pipesHeard"] == 0 + assert out["read"] == [{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 10}] + assert out["key"] is True # 칸 그대로 + cross = _src("A00_Common", "design", "design_view_cross.ts") + assert "watchDrafts(projectId," in cross and "onStructuresChanged" not in cross + panel = _src("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "onStructuresChanged" not in panel # 옛 되부름 걷음 + + +def test_change_signal_filters_option_only_pipe_edits(out: dict) -> None: + # 관 놓기 · 옵션만 · 옮기기 · 종류 바꿈 · 구조물 · 끊은 뒤 = 1 · 0 · 1 · 1 · 1 · 0 + assert out["watch"] == 4 + + +def test_kind_swap_rule_from_table(out: dict) -> None: + assert dict(out["swaps"]) == { + "mirror>curb": True, + "mirror>guardrail": False, + "refuge>work_yard": True, + "pipe>box_culvert": True, + "pipe>revetment": True, + "cross_underdrain>pipe": False, + } + + +def test_drawing_does_not_create_adjusts() -> None: + controls = _src("B06_Section", "B06_Section_UI_Page_Station_Controls.ts") + body = controls.split("syncApplied: (chainageM, role, applied) => {", 1)[1].split("},", 1)[0] + assert "if (!stored || sameAdjust(stored, applied)) return;" in body + + +def test_old_replay_removed() -> None: + assert "replayDraftPipeOptions" not in _src( + "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts" + ) diff --git a/resources/tester/test_b06_draft_pipe_replay.py b/resources/tester/test_b06_draft_pipe_replay.py index 349195287..4b034f681 100644 --- a/resources/tester/test_b06_draft_pipe_replay.py +++ b/resources/tester/test_b06_draft_pipe_replay.py @@ -5,6 +5,7 @@ 저장값(월류 폭 10 · 관경 1000)으로 돌아가 있었다. 초안(관 목록 세션 초안)이 정본이므로 횡단 보기가 서며 초안 관 옵션을 캐시에 다시 얹는다. • 되얹기 — 세월교 월류 폭 3.5 · 배수관 관경 1200 이 캐시 스펙에 실림 · 조정창 정본(기슭막이 높이 · 형태)은 안 건드림 + (323-6 — 옛 `replayDraftPipeOptions` 걷음 · 형상 얹기 한 벌이 초안 현황으로 다시 셈) • 횡단 보기가 먼저 선 뒤 종단 보기가 서도 종단 보기가 배수유역도의 관 목록을 먼저 쥠(빈 목록으로 시작해 관을 지우지 않음) 화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b06_draft_pipe_replay.cjs`). """ @@ -33,12 +34,15 @@ def test_draft_pipe_options_are_replayed_onto_the_cache(): out = json.loads(proc.stdout) assert out["fordSpan"] == 3.5 # 월류 폭 초안이 캐시 스펙에 실림(저장분 10 아님) assert out["culvertDiameter"] == 1.2 # 배수관 관경 초안 1200 - assert out["fordCalls"] == 1 # 옵션 없는 관은 건드리지 않고 세월교는 한 번만 + assert out["fordAt"] == [173.09] # 세월교는 제 측점에만 assert out["revetTouched"] is False # 기슭막이 높이 · 형태는 조정창이 정본 - # 배선 — 횡단 보기가 서며 초안을 읽어 되얹고, 종단 보기는 마지막 관 목록을 먼저 싣는다. + # 배선 — 옛 되얹기는 걷음(323-6) · 초안 현황으로 상세를 읽을 때마다 형상 얹기(`withDraftShapes`) · + # 종단 보기는 마지막 관 목록을 먼저 싣는다. cross = _read("A00_Common", "design", "design_view_cross.ts") - assert "replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId))" in cross + assert "replayDraftPipeOptions" not in cross + store = _read("B06_Section", "B06_Section_Section_Store.ts") + assert "await withDraftShapes(detail, projectId);" in store shared = _read("A00_Common", "design", "design_shared.ts") assert "shared.lastPipes = pipes" in shared profile = _read("A00_Common", "design", "design_view_profile.ts")