From 99a224c6c90d9c6fa2b7e0aa1f647564198dfbae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 21 Oct 2026 13:44:25 +0900 Subject: [PATCH] =?UTF-8?q?fix(505-2=20B05=20=C2=B7=20B06=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=C2=B7=20=EA=B3=A0=EB=A6=84):=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EC=A4=91=20=EA=B3=A0=EC=B9=A8=20=EC=95=8C=EB=A6=BC=20=C2=B7?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=A4=91=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EB=A7=89=EC=9D=8C=20+=20=EC=84=9C=EB=B2=84=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=C2=B7=20=EA=B3=84=EC=82=B0=20=EC=A4=91=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=ED=8F=84=20=C2=B7=20B06=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=B0=82=20=C2=B7=20=EC=B6=94=EA=B0=80=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?data-plus=20=C2=B7=20=EC=98=9B=20=EC=B4=88=EC=95=88=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20=ED=92=80=EA=B0=9C=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20309-3=20=C2=B7=20310?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1 가림막 저장(확정)도 도중 고침 검사 · 「저장 중 고친 값」 알림(키는 안 막음 · B07~B09 안 번짐) - 2b snapOptions 간격 = 10 만 · 옛 초안 0.1 · 1 m = null(load_station_snap_step 거울) - 3 추가 측점 = 측점 묶음 data-plus · B05 누름 · 가까이 표시가 그 속성으로 - 4d 숨은 동안 고른 카드 = 다음 다시 그림 끝에 밂 · 휠 · 누름이면 버림 - 7 pipe_calc set 다섯 = 풀개 · 횡단 · 종단 보기 onDispose - 8 저장 중 [초기화] = [저장]과 같은 알림 + 막음 · /route/reset = 프로젝트 잠금 안 - 9 isUserPick 거름 = 묶음 띄운 그 누름만 · 계산 중 새 누름 = 폄 - 시험 test_505_2_save_pick 새 · 398_1 busyPick 1 · 390_5 글 맞춤 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_page_data.ts | 11 +- A00_Common/design/design_reset.ts | 9 +- A00_Common/design/design_save.ts | 11 +- .../design/design_structure_pipe_calc.ts | 30 +++- A00_Common/design/design_user_pick.ts | 9 +- A00_Common/design/design_view_cross.ts | 11 +- A00_Common/design/design_view_profile.ts | 2 +- .../design/logic/design_pipeline_stations.ts | 11 +- B05_Profile/B05_Profile_Engine_Sections.py | 1 + B05_Profile/B05_Profile_Router_Lifecycle.py | 9 ++ .../B05_Profile_UI_Profile_CrossModal.ts | 2 +- B05_Profile/B05_Profile_UI_Profile_Render.ts | 3 +- B06_Section/B06_Section_UI_Longitudinal.ts | 2 + B06_Section/B06_Section_UI_Section_View.ts | 5 + resources/tester/helper_398_1_user_pick.cjs | 2 +- resources/tester/helper_505_2_save_pick.cjs | 53 +++++++ resources/tester/test_390_5_snap_step_ops.py | 4 +- .../tester/test_398_1_reveal_user_pick.py | 11 +- resources/tester/test_505_2_save_pick.py | 141 ++++++++++++++++++ 19 files changed, 289 insertions(+), 38 deletions(-) create mode 100644 resources/tester/helper_505_2_save_pick.cjs create mode 100644 resources/tester/test_505_2_save_pick.py diff --git a/A00_Common/design/design_page_data.ts b/A00_Common/design/design_page_data.ts index 1426b964d..74143063c 100644 --- a/A00_Common/design/design_page_data.ts +++ b/A00_Common/design/design_page_data.ts @@ -69,12 +69,17 @@ const notify = (projectId: string): void => { busyListeners.get(projectId)?.forEach((fn) => fn(now)); }; -/** 묶음 차례가 떠 있나 — 그 사이 폼이 다시 실려도 좌측 패널은 안 폄(`design_user_pick` · 398-1). */ -export const pageBusy = (projectId: string): boolean => (busy.get(projectId) ?? 0) > 0; +const launchedBy = new Map(); -/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */ +/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`design_user_pick` · 398-1 · 505-2). */ +export const pageLaunchedBy = (projectId: string, event: Event): boolean => + launchedBy.get(projectId) === event; + +/** 묶음 차례 하나가 뜸 — 띄운 누름을 적음 · 끝나면(밀림 · 실패 포함) 내림. */ export function trackPageBusy(projectId: string, run: Promise | null): void { if (!run) return; + const event = (globalThis as { event?: Event }).event; + if (event) launchedBy.set(projectId, event); busy.set(projectId, (busy.get(projectId) ?? 0) + 1); notify(projectId); const done = (): void => { diff --git a/A00_Common/design/design_reset.ts b/A00_Common/design/design_reset.ts index 44b660b48..20b78745e 100644 --- a/A00_Common/design/design_reset.ts +++ b/A00_Common/design/design_reset.ts @@ -18,6 +18,7 @@ import { clearResults, clearState, namesInBucket } from "./../b_page_state"; import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; +import { designSaveBusy, SAVE_BUSY_TEXT } from "./design_save"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -32,7 +33,13 @@ export async function resetDesign( _routeId: number | null, onCleared?: () => void, ): Promise { - if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; + // 저장 중 = [저장]처럼 알림 + 막음(505-2) — 확인 창 동안 뒤 저장이 떴을 수 있어 뒤에도 봄 + const saveBusy = (): boolean => { + if (designSaveBusy(projectId)) showToast(SAVE_BUSY_TEXT, "warning"); + return designSaveBusy(projectId); + }; + if (saveBusy() || !(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; + if (saveBusy()) return; showLoadingOverlay(); try { await resetRouteDesign(projectId); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index debd7b0f3..f814effb7 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -78,6 +78,8 @@ export type PartOverride = ( const saving = new Set(); /** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */ const inflight = new Map }>(); +/** 저장 중 막음 알림 — [저장] · [초기화] 같은 글(505-2). */ +export const SAVE_BUSY_TEXT = "이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요."; /** 저장이 도는 중인가(498-3) — 뒤에서 도는 [저장] 포함. */ export function designSaveBusy(projectId: string): boolean { @@ -95,7 +97,7 @@ export async function queueDesignSave( ): Promise { const before = inflight.get(projectId); if (before && (!confirm || before.confirm)) { - showToast("이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요.", "warning"); + showToast(SAVE_BUSY_TEXT, "warning"); return false; } if (before) showLoadingOverlay(); @@ -261,9 +263,12 @@ export async function saveDesign( route ? { route, carry: carried?.carry } : {}, ); if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info"); - // 잠금 없는 저장 도중 고침 — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸 본문을 저장함) + // 저장 도중 고침(가림막 동안 키 · 되돌리기 포함 · 505-2) — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸 + // 본문을 저장함) · 노선 저장은 다시 세움이 맡음 // ponytail: 고침 하나라도 있으면 조각 정리를 통째 건너뜀 · 다음 저장이 초안 전부를 다시 냄 - const touched = !locked && designSignature(ctx.projectId) !== sent; + const touched = !route && designSignature(ctx.projectId) !== sent; + if (touched && locked) + showToast("저장 중 고친 값은 아직 저장 안 됨 — 다시 [저장]해 주세요.", "warning"); if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」 if (!touched) for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]); clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index ee2e6a242..de52b3b7b 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -24,20 +24,32 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu /** 횡단 측점 목록 읽기 — 노면공 확폭 구간 상자(87-3)가 확폭을 읽음 · 자료 없으면 null. */ export const pipeSections = (): readonly CrossSection[] | null => sectionsSource?.() ?? null; -/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */ +/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. + * set 마다 풀개를 돌려줌(아직 제 값일 때만 비움) — 보기 onDispose 에 붙여 떠난 페이지를 놓음(505-2). */ let extraCountSource: ((section: CrossSection) => number) | null = null; -export function setPipeExtraCountSource(source: (section: CrossSection) => number): void { +export function setPipeExtraCountSource(source: (section: CrossSection) => number): () => void { extraCountSource = source; + return () => { + if (extraCountSource === source) extraCountSource = null; + }; } /** 유출 단 수를 쓰는 곳 — 횡단 보기의 단 수 제어(54-27) · 종단 보기는 횡단 보기를 숨긴 채 세워 씀. */ let extraCountSink: ((section: CrossSection, count: number) => void) | null = null; let crossOpener: (() => Promise) | null = null; -export function setPipeExtraCountSink(sink: (section: CrossSection, count: number) => void): void { +export function setPipeExtraCountSink( + sink: (section: CrossSection, count: number) => void, +): () => void { extraCountSink = sink; + return () => { + if (extraCountSink === sink) extraCountSink = null; + }; } -export function setPipeCrossOpener(opener: () => Promise): void { +export function setPipeCrossOpener(opener: () => Promise): () => void { crossOpener = opener; + return () => { + if (crossOpener === opener) crossOpener = null; + }; } /** 좌측 「추가 흙막이(단)」 → 주인 측점 유출 단 수(B06 횡단 조정과 같은 저장 자리 · 54-27). */ @@ -50,8 +62,11 @@ export async function setPipeExtraCount(chainageM: number | null, count: number) /** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */ type InletChoice = NonNullable["inlet_structure"]; let inletSource: ((section: CrossSection) => InletChoice) | null = null; -export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void { +export function setPipeInletSource(source: (section: CrossSection) => InletChoice): () => void { inletSource = source; + return () => { + if (inletSource === source) inletSource = null; + }; } /** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */ @@ -137,8 +152,11 @@ interface RevetChoiceBridge { ) => void; } let revetChoice: RevetChoiceBridge | null = null; -export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): void { +export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): () => void { revetChoice = bridge; + return () => { + if (revetChoice === bridge) revetChoice = null; + }; } /** 그 관의 [연동] · [경사] 지금 값 — 횡단 자료가 없으면 null(모름). */ diff --git a/A00_Common/design/design_user_pick.ts b/A00_Common/design/design_user_pick.ts index dbd062541..3d075da31 100644 --- a/A00_Common/design/design_user_pick.ts +++ b/A00_Common/design/design_user_pick.ts @@ -5,12 +5,13 @@ * * 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D · * 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶. - * 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음이 도는 동안 + * 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음을 띄운 그 누름 * (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기). + * 「계산 중」 동안 새로 누른 고름은 폄(505-2). * 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄. * ========================================================================== */ -import { pageBusy } from "./design_page_data"; +import { pageLaunchedBy } from "./design_page_data"; const PICK_EVENTS = new Set([ "pointerdown", @@ -22,9 +23,9 @@ const PICK_EVENTS = new Set([ "contextmenu", ]); -/** 지금 사용자 누름 안이고 묶음이 안 도는가 — 좌측 패널을 펴도 되는가. */ +/** 지금 사용자 누름 안이고 그 누름이 묶음을 안 띄웠나 — 좌측 패널을 펴도 되는가. */ export function isUserPick(projectId: string | null): boolean { const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지) if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false; - return !projectId || !pageBusy(projectId); + return !projectId || !pageLaunchedBy(projectId, event); } diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 9711831d4..ccee54e26 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -298,18 +298,21 @@ export async function mountCrossView( }, }); // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 - setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); + host.onDispose( + setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")), + ); // 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김 - setPipeExtraCountSink((owner, count) => { + const releaseSink = setPipeExtraCountSink((owner, count) => { const picked = stationControls.revetOffset.highlightFor(owner) ?? ""; if (/^extra\d+$/.test(picked) && Number(picked.slice(5)) >= count) stationControls.revetOffset.select(owner.chainage_m, "outlet"); stationControls.extraWalls.setCount(owner.chainage_m, count, "outlet"); }); + host.onDispose(releaseSink); // set 넷 = 떠난 페이지를 놓음(505-2) // 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선 - setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)); + host.onDispose(setPipeInletSource((section) => stationControls.inletStructure.valueFor(section))); // 좌측 관 보호공 [연동] · [경사] = 옛 연동 제어 그대로(54-30) - setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink)); + host.onDispose(setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink))); const sectionView = createSectionView( (chainageM, change) => { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 57ce81425..bf8e88b5e 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -222,7 +222,7 @@ export async function mountProfileView( restoring: true, }; // 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27) - setPipeCrossOpener(() => shared.ensureView("cross")); + host.onDispose(setPipeCrossOpener(() => shared.ensureView("cross"))); // 떠나면 놓음(505-2) // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index 204cc2b15..053a4d96b 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -48,14 +48,17 @@ export interface SavedSnap { station_snap_step?: number | null; } -/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). */ +/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). + * 간격 = 10 만 그 값 · 옛 초안 0.1 · 1 m 등 = null — 짝 `B05_Profile_Engine_Sections.py` load_station_snap_step(505-2). */ export function snapOptions( draft: RouteDraft | null | undefined, saved: SavedSnap | null | undefined, ): Pick { - return draft - ? { stationSnap: draft.stationSnap, stationSnapStep: draft.stationSnapStep } - : { stationSnap: saved?.station_snap ?? false, stationSnapStep: saved?.station_snap_step }; + const step = draft ? draft.stationSnapStep : saved?.station_snap_step; + return { + stationSnap: draft ? draft.stationSnap : (saved?.station_snap ?? false), + stationSnapStep: step === 10 ? 10 : null, + }; } /** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */ diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 4960428ae..4c91bf6dc 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -108,6 +108,7 @@ def load_station_snap_step(project_root: Path) -> float | None: """스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘). 10 만 그 값 · 칸 없음 · 옛 0.1 · 1 m 등 그 밖 = None(측점 No. · 280-4). + 짝 = `A00_Common/design/logic/design_pipeline_stations.ts` snapOptions(505-2 · 거울 시험). """ step = _read_design_curve_table(project_root).get("station_snap_step") return 10.0 if isinstance(step, int | float) and step == 10 else None diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 79abc5471..7adb46a34 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -236,6 +236,15 @@ async def reset_route_design( project_id: UUID, session: dict[str, Any] | None = Depends(get_optional_session), # 사본 가져온이 ) -> JSONResponse: + """[초기화] — [저장] · [확정]과 같은 프로젝트 잠금 안(505-2 · 저장 도중 초기화가 끼어 서로 덮지 않음 · + 몸통 `_reset_route_design` · 안에서 같은 잠금 안 잡음).""" + from common_util import common_util_save_guard as guard + + async with guard.project_lock(str(project_id)): + return await _reset_route_design(project_id, session) + + +async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None) -> JSONResponse: """B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼). **초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계 diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index 3f76da8ac..7acac045c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -59,7 +59,7 @@ export function crossPickFromEvent( ): { chainageM: number; groundY: number | null } | null { const target = event.target as Element; const marker = target.closest(".b06-chart__station"); - const plusRaw = marker?.querySelector(".b06-chart__station-line--plus") + const plusRaw = marker?.hasAttribute("data-plus") ? Number(marker.getAttribute("data-chainage")) : null; const hit: CrossPickHit = diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index c868e575e..bbb001486 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -336,8 +336,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples); if (at === null) return; const marker = (event.target as Element).closest(".b06-chart__station"); - if (marker && !marker.querySelector(".b06-chart__station-line--plus")) - event.stopPropagation(); + if (marker && !marker.hasAttribute("data-plus")) event.stopPropagation(); openCrossModal(body.parentElement ?? body, { chainageM: at.chainageM, stations: graphLongitudinal.stations.map((station) => station.chainage_m), diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 44b7f8fac..d24b60047 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -435,6 +435,8 @@ export function createLongitudinalProfile( role: "button", "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, }); + // 추가 측점 = 고른 뒤에도(선 클래스 --selected) B05 누름 판정이 읽음(505-2) + if (station.plus) marker.setAttribute("data-plus", "1"); marker.addEventListener("click", () => onSelectStation(station.station_id)); marker.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a31326e83..3fc2d15c8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -100,6 +100,8 @@ export function createSectionView( const cards = document.createElement("div"); cards.className = "b06-section__cards"; root.append(cards); + let pendingReveal: string | null = null; // 숨은 동안 고른 카드 — 다음 다시 그림 끝에 밂(505-2) + cards.onwheel = cards.onpointerdown = () => (pendingReveal = null); // 사용자 스크롤 · 누름 = 버림 let currentDetail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; /** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */ @@ -211,6 +213,7 @@ export function createSectionView( */ const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => { if (!stationId) return; + pendingReveal = stationId; document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" }); }; @@ -494,6 +497,8 @@ export function createSectionView( const keepScrollTop = cards.scrollTop; cards.replaceChildren(fillSlopeNotice.root, grid); cards.scrollTop = keepScrollTop; + if (pendingReveal) revealCard(pendingReveal, "auto"); + pendingReveal = null; offscreen.drain(); syncProfile(); }; diff --git a/resources/tester/helper_398_1_user_pick.cjs b/resources/tester/helper_398_1_user_pick.cjs index d344e7b0a..51e3d13fb 100644 --- a/resources/tester/helper_398_1_user_pick.cjs +++ b/resources/tester/helper_398_1_user_pick.cjs @@ -70,7 +70,7 @@ const count = async (event, body) => { await Promise.resolve(); onReveal(); // follow → 폼 다시 실음 }); - out.busyPick = await count(trusted("click"), () => onReveal()); // 차례가 도는 사이 고름은 안 폄 + out.busyPick = await count(trusted("click"), () => onReveal()); // 차례가 도는 사이 새 고름도 폄(505-2) finish(); await new Promise((resolve) => setTimeout(resolve, 0)); out.afterRun = await count(trusted("click"), () => onReveal()); // 차례가 끝나면 다시 폄 diff --git a/resources/tester/helper_505_2_save_pick.cjs b/resources/tester/helper_505_2_save_pick.cjs new file mode 100644 index 000000000..47e62b8b8 --- /dev/null +++ b/resources/tester/helper_505_2_save_pick.cjs @@ -0,0 +1,53 @@ +/* PLAN 505-2 단위검증 헬퍼 — 진짜 `design_pipeline_stations.ts` snapOptions(옛 초안 간격) · + * `design_structure_pipe_calc.ts` set 풀개(떠난 페이지를 놓음). 짝 = `test_505_2_save_pick.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function load(rel) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, () => ({})); + return box.exports; +} + +(async () => { + const out = {}; + // 2b — 초안 간격 · 저장 간격 → 측점 셈 간격(서버 load_station_snap_step 과 같아야 함) + const { snapOptions } = load("A00_Common/design/logic/design_pipeline_stations.ts"); + out.draft = [10, 0.1, 1, 20, null].map( + (step) => snapOptions({ stationSnap: true, stationSnapStep: step }, null).stationSnapStep, + ); + out.saved = [10, 0.1, 1, null].map( + (step) => snapOptions(null, { station_snap: true, station_snap_step: step }).stationSnapStep, + ); + out.snapKept = snapOptions({ stationSnap: true, stationSnapStep: 0.1 }, null).stationSnap; + + // 7 — 풀개는 자기 값일 때만 비움 + const calc = load("A00_Common/design/design_structure_pipe_calc.ts"); + const opened = { a: 0, b: 0 }; + const releaseA = calc.setPipeCrossOpener(async () => void (opened.a += 1)); + releaseA(); + await calc.setPipeExtraCount(null, 1); + out.afterRelease = opened.a; // 0 — 풀린 옛 페이지를 안 부름 + const releaseA2 = calc.setPipeCrossOpener(async () => void (opened.a += 1)); + calc.setPipeCrossOpener(async () => void (opened.b += 1)); + releaseA2(); // 옛 것 풀개 — 새 것은 그대로 + await calc.setPipeExtraCount(null, 1); + out.newKept = { ...opened }; + let sunk = 0; + const releaseSink = calc.setPipeExtraCountSink(() => void (sunk += 1)); + await calc.setPipeExtraCount(null, 1); + out.sinkOpened = opened.b; // sink 있으면 열지 않음 → 그대로 1 + releaseSink(); + await calc.setPipeExtraCount(null, 1); + out.sinkReleasedOpened = opened.b; // sink 풀림 → 새 횡단 보기를 엶 → 2 + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index 8baffd77c..6959c9fde 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -221,8 +221,8 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None: assert "stationSnapStep: routePlan.station_snap_step" in page stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8") assert "...snapOptions(ctx.inputs.route, saved?.())" in stage # 초안 · 없으면 저장 노선(441) - assert "stationSnapStep: draft.stationSnapStep" in stage - assert "stationSnapStep: saved?.station_snap_step" in stage + assert "draft ? draft.stationSnapStep : saved?.station_snap_step" in stage + assert "stationSnapStep: step === 10 ? 10 : null" in stage # 옛 초안 0.1 · 1 m = null(505-2) assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No. diff --git a/resources/tester/test_398_1_reveal_user_pick.py b/resources/tester/test_398_1_reveal_user_pick.py index 9d4c67853..61bff9e99 100644 --- a/resources/tester/test_398_1_reveal_user_pick.py +++ b/resources/tester/test_398_1_reveal_user_pick.py @@ -3,7 +3,8 @@ 못박는 것 - 좌측 패널 저절로 펴기는 사용자 고름만 — 진짜 누름(click · pointerup · contextmenu) 안 = 1. - 다시 칠하기 · 누가거리 따라가기 · 되돌리기 · 묶음 다시 돎 = 0 — 누름 밖(배수유역 다시 나눔 응답) · - 방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례가 떠 있는 동안(교점 놓음 · 곡선 지우기 · 되돌리기). + 방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례를 띄운 그 누름(교점 놓음 · 곡선 지우기 · 되돌리기) · + 「계산 중」 동안 새 누름 = 1(505-2). - 뿌리 한 곳 — 폼(`selectPipeByChainage` · `selectById` · `addAt`)은 그대로 펴기를 부르고 `design_shared` onReveal 하나가 `isUserPick` 으로 거름. - 499-1 뒤집음 — 펴기 = 「구조물 배치」 카드만 · 좌측 패널은 안 엶(`revealLeft` · `setOptionsOpen(` 없음) · @@ -41,14 +42,12 @@ def r() -> dict: return json.loads(proc.stdout) -@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun"]) +@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun", "busyPick"]) def test_사용자_고름은_폄(r, way): - assert r[way] == 1 + assert r[way] == 1 # busyPick = 「계산 중」 동안 새 누름(505-2) -@pytest.mark.parametrize( - "way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint", "busyPick"] -) +@pytest.mark.parametrize("way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint"]) def test_다시_칠하기는_안_폄(r, way): assert r[way] == 0 diff --git a/resources/tester/test_505_2_save_pick.py b/resources/tester/test_505_2_save_pick.py new file mode 100644 index 000000000..06fc40d80 --- /dev/null +++ b/resources/tester/test_505_2_save_pick.py @@ -0,0 +1,141 @@ +"""PLAN 505-2 B05 · B06 저장 · 고름 흠(요구 309-3 · C1 1 · 2b · 3 · 4d · 7 · 8 · 9). + +못박는 것 + - 1 확정 · 노선 아닌 가림막 저장 도중 고침도 조각 정리 건너뜀 + 「저장 중 고친 값」 알림 + (키는 안 막음 · B07~B09 안 번짐). + - 2b 옛 세션 초안 간격 0.1 · 1 m = null — 서버 `load_station_snap_step` 과 거울. + - 3 추가 측점 = 측점 묶음 `data-plus` — 고른 뒤(선 클래스 --selected)에도 B05 누름 판정이 읽음. + - 4d 숨은 동안 고른 카드 = 다음 다시 그림 끝에 밂 · 사용자 휠 · 누름이면 버림. + - 7 횡단 · 종단 보기가 건 set 넷 + 여는 이 = 풀개를 onDispose 에 — 자기 값일 때만 비움. + - 8 저장 중 [초기화] = [저장]과 같은 알림 + 막음 · 서버 /route/reset = 프로젝트 잠금 안. + - 9 = `test_398_1_reveal_user_pick.py` busyPick 1. + +./venv/Scripts/python.exe -m pytest resources/tester/test_505_2_save_pick.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +HELPER = Path(__file__).resolve().parent / "helper_505_2_save_pick.cjs" +DESIGN = "A00_Common/design/" + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603, S607 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def _server_step(tmp_path: Path, step: float | None) -> float | None: + from B05_Profile.B05_Profile_Engine_Sections import load_station_snap_step + from common_util.common_util_route_geometry import planned_route_working_path + + path = planned_route_working_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x,y\n0,0\n10,0\n", encoding="utf-8") + curves = {"curves": [], "station_snap": True, "station_snap_step": step} + path.with_name(f"{path.stem}_curves.json").write_text(json.dumps(curves), encoding="utf-8") + return load_station_snap_step(tmp_path) + + +def test_2b_초안_간격_서버와_거울(out, tmp_path) -> None: + steps = [10, 0.1, 1, 20, None] + assert ( + out["draft"] == [_server_step(tmp_path, s) for s in steps] == [10, None, None, None, None] + ) + assert out["saved"] == [10, None, None, None] + assert out["snapKept"] is True # 스냅 켬은 그대로 · 간격만 측점 No. + + +def test_7_풀개는_자기_값만(out) -> None: + assert out["afterRelease"] == 0 + assert out["newKept"] == {"a": 0, "b": 1} + assert out["sinkOpened"] == 1 and out["sinkReleasedOpened"] == 2 + cross = _read(DESIGN + "design_view_cross.ts") + for call in ("setPipeExtraCountSource(", "setPipeInletSource(", "setPipeRevetChoiceBridge("): + assert f"host.onDispose(\n {call}" in cross or f"host.onDispose({call}" in cross, call + assert "const releaseSink = setPipeExtraCountSink(" in cross + assert "host.onDispose(releaseSink);" in cross + profile = _read(DESIGN + "design_view_profile.ts") + assert 'host.onDispose(setPipeCrossOpener(() => shared.ensureView("cross")));' in profile + + +def test_1_가림막_저장도_도중_고침_검사() -> None: + save = _read(DESIGN + "design_save.ts") + assert "const touched = !route && designSignature(ctx.projectId) !== sent;" in save + assert "if (touched && locked)" in save and "저장 중 고친 값은 아직 저장 안 됨" in save + assert "!locked && designSignature" not in save + elements = _read("ui_template/ui_template_elements.ts") + assert "keydown" not in elements[elements.index("function ensureOverlay") :][:1200] + + +def test_8_저장_중_초기화_막음() -> None: + reset = _read(DESIGN + "design_reset.ts") + body = reset[reset.index("export async function resetDesign(") :] + assert body.index("saveBusy()") < body.index("showConfirmDialog(") + assert "if (saveBusy()) return;" in body # 확인 창 동안 뜬 뒤 저장도 + assert "designSaveBusy(projectId)" in body and "SAVE_BUSY_TEXT" in body + save = _read(DESIGN + "design_save.ts") + assert save.count("showToast(SAVE_BUSY_TEXT") == 1 # [저장]과 같은 글 + + +def test_8_서버_초기화는_프로젝트_잠금_안(monkeypatch) -> None: + from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle + from common_util import common_util_save_guard as guard + + pid = uuid4() + seen: list[bool] = [] + + async def body(project_id, session): + seen.append(guard.project_lock(str(project_id)).locked()) + return "ok" + + monkeypatch.setattr(lifecycle, "_reset_route_design", body) + assert asyncio.run(lifecycle.reset_route_design(pid, None)) == "ok" + assert seen == [True] + assert not guard.project_lock(str(pid)).locked() + src = _read("B05_Profile/B05_Profile_Router_Lifecycle.py") + inner = src[src.index("async def _reset_route_design(") :] + assert "project_lock" not in inner # 안에서 같은 잠금 다시 안 잡음(교착) + + +def test_3_추가_측점은_data_plus() -> None: + longi = _read("B06_Section/B06_Section_UI_Longitudinal.ts") + assert 'if (station.plus) marker.setAttribute("data-plus", "1");' in longi + modal = _read("B05_Profile/B05_Profile_UI_Profile_CrossModal.ts") + render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts") + assert 'marker?.hasAttribute("data-plus")' in modal + assert '!marker.hasAttribute("data-plus")' in render + assert "station-line--plus" not in modal + render + + +def test_4d_숨은_동안_고름은_다음_그림에_밂() -> None: + view = _read("B06_Section/B06_Section_UI_Section_View.ts") + assert len(view.splitlines()) <= 700 + reveal = view[view.index("const revealCard = ") :][:300] + assert "pendingReveal = stationId;" in reveal + draw = view[view.index("cards.scrollTop = keepScrollTop;") :][:200] + assert 'if (pendingReveal) revealCard(pendingReveal, "auto");' in draw + assert "pendingReveal = null;" in draw + assert "cards.onwheel = cards.onpointerdown = () => (pendingReveal = null);" in view + + +def test_파이프라인_페이지_안_늘림() -> None: + assert len(_read(DESIGN + "design_pipeline_page.ts").splitlines()) <= 699