diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 79421bcc5..0c9e13429 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -249,7 +249,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { }; /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ - const { tools, history, handleToolPick, setListening } = createPanelTools({ + const { tools, history, handleToolPick, setListening, disposeTools } = createPanelTools({ root, base: () => base, alignment: () => alignment, @@ -262,7 +262,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { moveStation: (station, toChainageM) => callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), // 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다. - drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined }, + drainage: drainagePanel ?? { setPipeChainages: () => undefined }, // 세션이 정본 — 가게가 편집 초안을 다시 읽음(노선 초안 동안은 노선 0 칸) · 계획고가 그대로면 다시 셈 예약 없음(349-0q) restore: () => void (store.resync() ? rebuild() : draw()), restoreSaved: () => store.restoreSaved(), @@ -666,6 +666,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { dispose() { pagePipeline.disposePagePipeline(projectId); setListening(false); + disposeTools(); drag.dispose(); crossPreview.dispose(); unbindViewport(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 816f28d60..a947d28ca 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -333,6 +333,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits()); facilityStore.replaceFromResponse(response.pipe_points); if (fordFilled) rememberPipes(); + if (saved?.response === response) saved.request = JSON.stringify(pipeRequest()); // 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직 // 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다. lastNotifiedPipeChainage = null; @@ -368,6 +369,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra })), ); + /** 마지막 저장분 답 · 그 답을 실은 뒤 요청 몸통 — 초안 없는 걸음으로 되돌릴 때 견줌(472 덧). */ + let saved: { response: DetailBasinResponse; request: string | null } | null = null; + /** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */ function rememberPipes(): void { if (!projectId) return; @@ -380,7 +384,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pending = readPendingPipes(projectId as string); return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감 ? computeDetailBasins(projectId as string, pending) - : fetchDetailPipePoints(projectId as string); + : fetchDetailPipePoints(projectId as string).then( + (response) => ((saved = { response, request: null }), response), + ); }; /** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김). @@ -529,6 +535,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra root, load(nextProjectId: string) { if (projectId === nextProjectId && meta) return; + if (projectId !== nextProjectId) saved = null; projectId = nextProjectId; void loadLayers(); }, @@ -563,6 +570,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra selectBasin(picked ? picked.index : null, false); }, setPipeChainages(chainages) { + // 초안 없는 걸음 = 저장분 — 마지막 저장분 그대로면 끝 · 아니면 조용히 다시 읽음(472 덧) + if (chainages === null) + return void (JSON.stringify(pipeRequest()) !== saved?.request && run(loadSaved, true)); const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m); const next = reconcilePipes(pipeEditor.pipes(), chainages); if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 3088b0ca3..e30eba54a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -37,8 +37,9 @@ export interface DrainagePanel { /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ movePipe: (fromChainage: number, toChainage: number) => void; /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). - * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */ - setPipeChainages: (chainages: ReadonlyArray) => void; + * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. + * null = 초안 없는 걸음(되돌리기) — 저장분으로(472 덧). */ + setPipeChainages: (chainages: ReadonlyArray | null) => void; /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때. * attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */ addPipe: (chainageM: number, attributes?: FacilityAttributes) => void; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 3273f4c69..fdcc2d9ec 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -20,6 +20,7 @@ import { adjustStation } from "./logic/B05_Profile_UI_Profile_Alignment"; import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { createProfileHistory, + onPageHistory, setPageHistory, type ProfileHistory, } from "./B05_Profile_UI_Profile_History"; @@ -63,8 +64,7 @@ export interface PanelToolsContext { moveStation: (station: IrregularStation, toChainageM: number) => void; /** 관 목록 정본 — 되돌리기가 구조물 위치까지 되돌리려면 이력이 이 값을 봐야 한다. */ drainage: { - pipeChainages: () => number[]; - setPipeChainages: (chainages: number[]) => void; + setPipeChainages: (chainages: number[] | null) => void; }; /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ restore: () => void; @@ -83,38 +83,26 @@ export interface PanelTools { handleToolPick: (chainageM: number | null) => boolean; /** 창 키 듣기를 잇고 뗀다 — 종단도가 숨거나 떠나면 뗀다(계획서 24-3a). */ setListening: (on: boolean) => void; + /** 사진첩 알림을 뗀다 — 종단도를 버릴 때. */ + disposeTools: () => void; } export function createPanelTools(ctx: PanelToolsContext): PanelTools { - /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ - let initialPipes: number[] | null = null; - - /** 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이 정본 — 찍기 직전엔 처음 본 목록만 잡아 둔다. */ - function syncPipes(): void { - if (initialPipes === null) initialPipes = ctx.drainage.pipeChainages(); - } - - /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다(초안 없는 걸음 = 처음 목록). 같은 목록이면 조용히 끝난다. */ + /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다 — 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이 + * 정본 · 초안 없는 걸음 = 저장분(null · 옛 「처음 본 목록」은 판이 읽기 전 빈 목록이라 관 하나 지움 ↶ 이 + * 같은 목록으로 멈췄음 · 472 덧). 같은 목록이면 조용히 끝난다. */ function restorePipes(): void { const draft = useDesignStore(draftProjectId(), null).get("pipes"); - const chainages = draft - ? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100) - : initialPipes; - if (chainages) ctx.drainage.setPipeChainages(chainages); + ctx.drainage.setPipeChainages( + draft ? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100) : null, + ); } // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). - /** 찍기 직전(세울 때 · 쉬는 동안 온 값 덮을 때 · 연속 조작 멈출 때) 처음 본 관 목록을 잡는다 — - * 그래야 그 목록이 조작 **전** 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측). */ - const inner = createProfileHistory( - () => { - ctx.restore(); - restorePipes(); - }, - 100, - syncPipes, - ); - const history: ProfileHistory = { ...inner, pause: () => (syncPipes(), inner.pause()) }; + const history = createProfileHistory(() => { + ctx.restore(); + restorePipes(); + }, 100); setPageHistory(history); // 페이지 사진첩 하나 — 노선 편집기도 이것으로 쌓고 되돌림(346-4) // [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1). @@ -173,10 +161,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { }, canResetToSaved: ctx.canRestoreSaved, // 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림. - onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()), - onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()), - canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()), - canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()), + onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : history.undo()), + onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : history.redo()), + canUndo: () => (tools.mode() === "free" ? free.canUndo() : history.canUndo()), + canRedo: () => (tools.mode() === "free" ? free.canRedo() : history.canRedo()), hasStraightRuns: () => { const alignment = ctx.alignment(); return alignment ? hasStraightRuns(alignment) : false; @@ -400,5 +388,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { window.addEventListener("keydown", onEscape, true); } else stopHold(); }; - return { tools, history, handleToolPick, setListening }; + // 삭제 직후 등 다시 그리기 전에도 ↶ ↷ 켜짐을 맞춤(472 덧 — 종단 ↶ 가 꺼진 채 남던 흠) + const disposeTools = onPageHistory({ change: () => tools.syncHistory() }); + return { tools, history, handleToolPick, setListening, disposeTools }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index bf76034b2..d6a51f530 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -50,6 +50,8 @@ export interface ProfileToolsCallbacks { export interface ProfileTools { /** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */ render: () => HTMLElement; + /** 사진첩이 쌓이거나 되돌린 뒤 ↶ ↷ 켜짐만 맞춤 — 다시 그리기를 안 기다림(472 덧). */ + syncHistory: () => void; mode: () => ProfileToolMode; /** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */ handleStationPick: (chainageM: number) => boolean; @@ -88,6 +90,8 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo let mode: ProfileToolMode = "none"; let pending: number | null = null; let runs: StraightRun[] = []; + /** 마지막에 그린 ↶ ↷ — 다시 그리기 사이에도 켜짐을 맞춤. */ + let historyButtons: [HTMLButtonElement, HTMLButtonElement] | null = null; function reset(): void { mode = "none"; @@ -172,6 +176,11 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo callbacks.onChanged(); return true; }, + syncHistory() { + if (!historyButtons) return; + historyButtons[0].disabled = !callbacks.canUndo(); + historyButtons[1].disabled = !callbacks.canRedo(); + }, render() { const wrap = document.createElement("span"); wrap.className = "b05-route-profile__tools"; @@ -193,6 +202,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo undo.setAttribute("aria-label", "되돌리기"); redo.innerHTML = HISTORY_ICON.redo; redo.setAttribute("aria-label", "다시하기"); + historyButtons = [undo, redo]; const straighten = toolButton( "직선화", diff --git a/docs/wiki/concepts/design_page/operations_b05.md b/docs/wiki/concepts/design_page/operations_b05.md index 352272882..e4911a705 100644 --- a/docs/wiki/concepts/design_page/operations_b05.md +++ b/docs/wiki/concepts/design_page/operations_b05.md @@ -28,7 +28,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) | |---|---|---|---|---|---|---| | 구조물 추가 · 옮김 · 지움 | 보기 메모리(B05 `ownStructures` · B06 `structures`) → 시설 줄 `set` → 세션 `structures` | 시설 줄(알약 · 비정규 측점 · 소단 사본 `berm`) · 묶음 stations → 갈아끼움(정본 표본 유지) · 짝 있는 옮김만 종단 꼭짓점 따라감 · 짝 없는 새 측점 = 꼭짓점 안 세움 · cross 면적 | — | `PUT /route/structures`(초안 그대로) → 횡단 차례 측점 세움 · 면적 Node | 11 | `design_shared.ts:303-322` · `B05_Profile_UI_Page_Structures.ts:98-118` · `design_facility_lane.ts:209-217` · `common_util_structure_station.ts:356` · `B05_Profile_Structures_Router.py:386-405` | -| 관 추가 · 옮김 · 지움(B05 배수판) | 배수판 메모리(`facilityStore` · `pipeEditor`) → 세션 `pipes` · `pipe-ids` | 구조물과 같음 · 관 길이 다시 셈 · 일반 측점 자리 관 지움 = 갈아끼움 · 관 지움 ↶ = 그 측점 정본 설계 · 열린 관 폼은 ↶ ↷ 로 옮겨진 관을 따라감(`B05_Profile_UI_Structures_Panel.ts` setPipeFacilities) | 배수유역 `/drainage/detail-basins`(저장 안 함) | `PUT /drainage/pipe-points`(관 차례) · 관 옵션 예약 있으면 같은 파일 한 번 더(관 옵션 차례) · 서버 유역 · 측점 세움 | 14 | `B05_Profile_UI_Drainage_Panel.ts:150-153 · 405-421 · 574-655` · `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B04_PreProcess_Router_Basins.py:538 · 574-633` | +| 관 추가 · 옮김 · 지움(B05 배수판) | 배수판 메모리(`facilityStore` · `pipeEditor`) → 세션 `pipes` · `pipe-ids` | 구조물과 같음 · 관 길이 다시 셈 · 일반 측점 자리 관 지움 = 갈아끼움 · 관 지움 ↶ = 그 측점 정본 설계 · 관 초안 없는 걸음으로 ↶ = 저장분(배수판 · 시설 줄 · 알약 · 목록 · 배수유역도 함께 · 472 덧) · 열린 관 폼은 ↶ ↷ 로 옮겨진 관을 따라감(`B05_Profile_UI_Structures_Panel.ts` setPipeFacilities) | 배수유역 `/drainage/detail-basins`(저장 안 함) | `PUT /drainage/pipe-points`(관 차례) · 관 옵션 예약 있으면 같은 파일 한 번 더(관 옵션 차례) · 서버 유역 · 측점 세움 | 14 | `B05_Profile_UI_Drainage_Panel.ts:150-153 · 405-421 · 574-655` · `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B04_PreProcess_Router_Basins.py:538 · 574-633` | | 추가 측점 | 세션 `plus-stations` | 시설 줄 · 묶음 stations · 갈아끼움 · 미리보기 `/section/preview-stations` · 꼭짓점 안 세움 | — | `PUT /section/plus-stations` · 서버 x · y · 측점 세움 | 5 | `B05_Profile_UI_Profile_CrossModal.ts:205-246` · `B05_Profile_Api_PlusStations.ts:44-76 · 128-145` · `B06_Section_Router_Stations.py:428-472` | ## 종단(계획고) diff --git a/docs/wiki/concepts/design_page/operations_common.md b/docs/wiki/concepts/design_page/operations_common.md index a1af741f6..129643af5 100644 --- a/docs/wiki/concepts/design_page/operations_common.md +++ b/docs/wiki/concepts/design_page/operations_common.md @@ -12,7 +12,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) | |---|---|---|---|---|---|---| -| ↶ ↷ | 사진첩 = 세션 draft 칸 글(고름 · 관 id 등 뺌) · 빈 칸 「""」·「{}」·「null」 = 칸 없음 · 「[]」 = 값 | 칸 되씀 → 종단 · 시설 · 노선 다시 먹임 · 횡단 입력 물러섬 = 저장 정본 | — | — | 사진 n | `B05_Profile_UI_Profile_History.ts:50 · 57 · 186 · 226-245` · `design_pipeline_facilities.ts:62-69` · `B05_Profile_UI_RouteEdit_History.ts:174-180` | +| ↶ ↷ | 사진첩 = 세션 draft 칸 글(고름 · 관 id 등 뺌) · 빈 칸 「""」·「{}」·「null」 = 칸 없음 · 「[]」 = 값 | 칸 되씀 → 종단 · 시설 · 노선 다시 먹임 · 횡단 입력 물러섬 = 저장 정본 · 관 초안 없는 걸음 = 저장분(배수판이 마지막 저장분 답과 견줘 다르면 다시 읽음) · ↶ ↷ 켜짐 = 쌓임 · 되돌림 알림(`change`)으로 바로 | — | — | 사진 n | `B05_Profile_UI_Profile_History.ts:50 · 57 · 186 · 226-245` · `design_pipeline_facilities.ts:62-69` · `B05_Profile_UI_RouteEdit_History.ts:174-180` · `B05_Profile_UI_Profile_Panel_Tools.ts` restorePipes · `B05_Profile_UI_Drainage_Panel.ts` setPipeChainages | | B05 ↔ B06 전환 | 없음(보기 숨김 · 보임) | 보인 보기가 가게 · 세션 다시 읽음 · 입력 대 묶음 맞춤(최대 3 바퀴) · 시설 줄 · 묶음은 숨은 동안도 셈 | — | — | — | `design_page.ts:209-262` · `design_pipeline_page.ts:559-582` · `design_view_cross.ts:557-565` · `design_view_profile.ts:427` | | 종단 판 접기 | pref `profile-collapsed` | 본문 0 크기 = 파생값(낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료)만 셈 · 그리기만 건너뜀 | — | — | — | `B05_Profile_UI_Profile_Render.ts:131-159 · 211-226` · `B05_Profile_UI_Profile_Drag.ts:43-58` | | 팝아웃 | 없음 — 종단 판 DOM 만 새 창 | 같은 함수 · 같은 가게 | — | — | — | `design_profile_popout.ts:87-132` | diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 0262b9440..104153867 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -55,7 +55,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 | | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | | 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 | -| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · 암선 · 암 경사 · 상단측 = 세션 칸이 주인(사본 없음 · 472-5) | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore · `B06_Section_UI_Page_Persist.ts` readCells | 관 · 구조물 = `test_472_5_shop_owner` · 조정창 = `test_472_5d_adjust_owner` · 암선 · 암 경사 = `test_472_5r_rock_owner` | +| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · 암선 · 암 경사 · 상단측 = 세션 칸이 주인(사본 없음 · 472-5) | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore · `B06_Section_UI_Page_Persist.ts` readCells | 관 · 구조물 = `test_472_5_shop_owner` · 관 초안 없는 걸음 = 저장분 · ↶ 켜짐 = `test_472_d_b05_pipe_undo` · 조정창 = `test_472_5d_adjust_owner` · 암선 · 암 경사 = `test_472_5r_rock_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/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index fc88620ae..04aa20ff3 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -46,6 +46,7 @@ const STUBS = { canRedo: () => false, }), setPageHistory: () => undefined, + onPageHistory: () => () => undefined, // ↶ ↷ 켜짐 맞춤(472 덧) · 이 흠과 무관 }, "./B05_Profile_UI_Profile_Tools": { createProfileTools: () => ({ mode: () => "none", clear: () => undefined }), @@ -95,7 +96,7 @@ tools.createPanelTools({ irregularStations: () => [pipe], stationIdOf: (s) => `irregular:${s.id}`, moveStation: (s, to) => calls.move.push([s.chainage_m, to]), - drainage: { pipeChainages: () => [], setPipeChainages: () => undefined }, + drainage: { setPipeChainages: () => undefined }, restore: () => undefined, restoreSaved: () => undefined, canRestoreSaved: () => false, diff --git a/resources/tester/helper_472_d_b05_pipe_undo.cjs b/resources/tester/helper_472_d_b05_pipe_undo.cjs new file mode 100644 index 000000000..4a2d65aa9 --- /dev/null +++ b/resources/tester/helper_472_d_b05_pipe_undo.cjs @@ -0,0 +1,125 @@ +/* 472 덧 헬퍼 — B05 보기 관 지움 ↶ 가 반쪽만 돌아오던 흠 · 종단 ↶ 단추가 다시 그리기 전까지 꺼져 있던 흠. + * 실제 TS(`B05_Profile_UI_Profile_Panel_Tools` · `B05_Profile_UI_Profile_History` · 가게)를 그 자리에서 + * 트랜스파일해 Node 로 돌림 · 배수 판은 흉내(받은 관 목록만 적음) · 결과 = 표준출력 JSON. 짝 = `test_472_d_b05_pipe_undo.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const listeners = {}; +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(), + }; +}; +globalThis.sessionStorage = storage(); +globalThis.localStorage = storage(); +globalThis.window = { + sessionStorage: globalThis.sessionStorage, + localStorage: globalThis.localStorage, + addEventListener: (type, fn) => (listeners[type] ??= new Set()).add(fn), + removeEventListener: (type, fn) => listeners[type]?.delete(fn), + setTimeout: () => 0, + clearTimeout: () => undefined, +}; +globalThis.document = { addEventListener: () => undefined, querySelector: () => null }; + +// 도구줄 · 자유지정 그림은 흉내 — ↶ ↷ 켜짐 맞춤 부름 수만 셈 +let synced = 0; +const STUBS = { + "./B05_Profile_UI_Profile_Tools": { + createProfileTools: () => ({ + mode: () => "none", + clear: () => undefined, + syncHistory: () => void (synced += 1), + }), + }, + "../A00_Common/design/logic/design_profile_free": { + createFreeDraw: () => ({ key: () => false, hint: () => "" }), + }, + "../A00_Common/design/design_profile_free_view": { + createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), + }, +}; + +function loadTs(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 localRequire = (request) => + (CATALOG.isCatalogRequest(request) ? CATALOG : null) ?? + STUBS[request] ?? + (request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request)); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const store = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Panel_Tools.ts")); + +const P = "p472d"; +localStorage.setItem("frd_current_project_id", P); +store.pinDraftProject(P); +const shop = store.useDesignStore(P, null); + +// 배수 판 흉내 — 판이 저장분을 읽기 전(빈 목록)에 사진첩이 섬 · 관 하나(351) 프로젝트 +let shown = []; +const given = []; +const panel = tools.createPanelTools({ + root: { isConnected: true }, + base: () => ({}), + alignment: () => null, + edits: () => ({}), + savedRadii: () => ({}), + applyEdits: () => undefined, + selectedStationId: () => null, + irregularStations: () => [], + stationIdOf: (s) => s.id, + moveStation: () => undefined, + drainage: { + pipeChainages: () => shown, // 옛 「처음 본 목록」 길이 부름 + setPipeChainages: (chainages) => given.push(chainages), + }, + restore: () => undefined, + restoreSaved: () => undefined, + canRestoreSaved: () => false, + refresh: () => undefined, +}); +shown = [351]; // 판이 저장분을 읽음 +const touch = () => listeners.keydown?.forEach((fn) => fn({ key: "a", target: null })); + +// 종단 우클릭 [배관 삭제] — 판이 관을 빼고 가게 관 초안을 빈 목록으로 씀 +touch(); +const syncedBefore = synced; +shown = []; +shop.set("pipes", []); +panel.history.record(); +const afterDelete = { canUndo: panel.history.canUndo(), synced: synced - syncedBefore }; + +panel.history.undo(); +const undo = { given: given.at(-1), draft: shop.get("pipes") ?? null }; +panel.history.redo(); +const redo = { given: given.at(-1), draft: shop.get("pipes") ?? null }; +panel.history.undo(); +const undo2 = { given: given.at(-1) }; + +process.stdout.write(JSON.stringify({ afterDelete, undo, redo, undo2 })); diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py index 06c5a458a..3d253318e 100644 --- a/resources/tester/test_215_1_free_profile.py +++ b/resources/tester/test_215_1_free_profile.py @@ -135,8 +135,8 @@ def test_적용_막히면_열린_채(out): def test_되돌리기_배선_자유지정_먼저(): panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") - assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()' in panel - assert 'tools.mode() === "free" ? free.canUndo() : inner.canUndo()' in panel + assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : history.undo()' in panel + assert 'tools.mode() === "free" ? free.canUndo() : history.canUndo()' in panel keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :] free_at = keys.index('if (tools.mode() === "free") {') assert ( diff --git a/resources/tester/test_472_d_b05_pipe_undo.py b/resources/tester/test_472_d_b05_pipe_undo.py new file mode 100644 index 000000000..a8298c066 --- /dev/null +++ b/resources/tester/test_472_d_b05_pipe_undo.py @@ -0,0 +1,81 @@ +"""PLAN 472 덧 (요구 272) — B05 보기 관 지움 ↶ 가 반쪽만 돌아옴 · 배수유역도도 안 돌아옴. + +까닭: ↶ 가 관 초안 없는 걸음(저장분)으로 가면 종단 사진첩이 「처음 본 관 목록」을 배수 판에 +줬는데, 그 목록은 판이 저장분을 읽기 전에 잡혀 빈 목록 — 관 하나 프로젝트에서 지운 뒤 판 목록 +(빈 목록)과 같아 판이 「같은 목록」으로 멈춤 → 배수 판 · 시설 줄 · 알약 · 목록은 관 없음 · +묶음은 저장분 관(측점 57) · 누가토량 −17,385.4. B06 보기 ↶ 도 같은 길(배수유역도 관 · 세부유역 +빠짐). 가게 구독은 빈 초안(null)을 안 받음. 또 종단 ↶ 단추는 다시 그릴 때만 켜짐을 맞춰 삭제 +직후 꺼져 있었음. +고침: 초안 없는 걸음 = null(저장분) → 판이 마지막 저장분 답과 견줘 다르면 조용히 다시 읽음 · +사진첩 쌓임 · 되돌림 알림에 ↶ ↷ 켜짐 맞춤. TS 는 `helper_472_d_b05_pipe_undo.cjs` 가 Node 로 돌림. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_d_b05_pipe_undo.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_472_d_b05_pipe_undo.cjs")], + 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 out["undo"] == {"given": None, "draft": None} + assert out["undo2"]["given"] is None + + +def test_다시하기는_그_걸음_초안(out) -> None: + assert out["redo"] == {"given": [], "draft": []} + + +def test_삭제_직후_되돌리기_단추_켜짐(out) -> None: + """옛 코드 = 종단 도구줄은 다시 그릴 때만 ↶ 켜짐을 맞춤(맞춤 0 번 · 빨강).""" + assert out["afterDelete"]["canUndo"] is True + assert out["afterDelete"]["synced"] >= 1 + + +def test_배수_판_저장분_견줌() -> None: + panel = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + # 저장분 답을 받은 때만 견줄 몸통을 적음 · 초안 없는 걸음은 다르면 조용히 다시 읽음 + assert "(response) => ((saved = { response, request: null }), response)" in panel + assert ( + "if (saved?.response === response) saved.request = JSON.stringify(pipeRequest());" in panel + ) + assert ( + "return void (JSON.stringify(pipeRequest()) !== saved?.request && run(loadSaved, true));" + in panel + ) + assert "if (projectId !== nextProjectId) saved = null;" in panel + tools = _read("B05_Profile", "B05_Profile_UI_Profile_Panel_Tools.ts") + assert "initialPipes" not in tools + profile = _read("A00_Common", "design", "design_profile.ts") + assert "disposeTools();" in profile