From 322725bf9c369cbb21e95e2f1c3f38ca2b9bc94f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:17:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?):=20346-4=20=ED=95=9C=20=EC=A4=84=20=E2=80=94=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=EC=B2=A9=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=95=A9?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+Z · Y · 편집기 [되돌리기] · [다시하기] · [반영 전으로] = 페이지 사진첩(_Profile_History) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로 - 편집기 쌓기 · [반영 전으로] 뒤 사진첩 한 걸음(_Draft.watch) · 사진첩이 되쓴 route-draft 는 fromPage 로 편집기 새 걸음(같은 선 옛 걸음의 고정 · 추천 꼬리표째) - 쌓는 줄 없는 조작(횡단 카드 등) = 놓은 뒤 · change 뒤 400 ms 한 번 찍음 · 고름 · 관 id · 종류 캐시 · 구조물 넘김 · b08 · b09 칸은 사진 밖 - 「마지막에 만진 판만」(_RouteEdit_UndoOwner) 지움 · 편집기는 단축키 안 들음 - 시험 test_346_4_one_undo.py · 345-3 시험 · 219-1 헬퍼 새 꼴로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_UI_Profile_History.ts | 49 +++++- .../B05_Profile_UI_Profile_Panel_Tools.ts | 14 +- B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts | 47 ++++- .../B05_Profile_UI_RouteEdit_History.ts | 65 ++++--- .../B05_Profile_UI_RouteEdit_UndoOwner.ts | 33 ---- resources/tester/helper_219_1_arrow_owner.cjs | 1 + resources/tester/helper_345_3_undo_owner.cjs | 37 +--- resources/tester/helper_346_4_one_undo.cjs | 161 ++++++++++++++++++ resources/tester/test_345_3_route_body.py | 33 +--- resources/tester/test_346_4_one_undo.py | 68 ++++++++ 10 files changed, 385 insertions(+), 123 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts create mode 100644 resources/tester/helper_346_4_one_undo.cjs create mode 100644 resources/tester/test_346_4_one_undo.py diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 92f3736fb..77210a805 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -14,11 +14,21 @@ * 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4). * 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번. * 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남. + * + * 페이지 사진첩 하나(346-4) — 평면(노선 편집) · 종단 · 횡단 · 관 · 구조물이 같은 줄. 노선 편집기는 + * `pageHistory()` 로 쌓고 되돌리며 `onPageHistory` 로 되살림 · 단추 켜짐을 받음. 고름 · 관 id 장부 · + * 종류 캐시 · 구조물 넘김 · 다른 페이지(b08 · b09) 칸은 조작이 아니라 사진 밖. + * 놓은 뒤 · 칸 값 바꾼 뒤(change) 잠깐 기다려 한 번 더 찍음 — 쌓는 줄이 따로 없는 조작(횡단 카드 등)도 한 걸음. * ========================================================================== */ /** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ const DRAFT_PREFIX = "aislo:draft:"; const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; +/** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */ +const NOT_EDIT = + /^aislo:draft:(selection|pipe-ids|structure-type-cache|structure-pick|b08-[^:]*|b09-[^:]*):/; +/** 놓은 뒤 · 칸 바꾼 뒤 기다렸다 찍는 시간(ms) — 서버 다시 셈 뒤 초안 쓰기까지 한 걸음에. */ +const SETTLE_MS = 400; /** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ function isLayoutKey(key: string): boolean { @@ -32,7 +42,7 @@ function isLayoutKey(key: string): boolean { } function isDataKey(key: string): boolean { - if (key.startsWith(DRAFT_PREFIX)) return true; + if (key.startsWith(DRAFT_PREFIX)) return !NOT_EDIT.test(key); return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); } @@ -66,6 +76,34 @@ function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean { let pointerHeld = false; const releaseWaiters = new Set<() => void>(); let pointerWatched = false; +let settleTimer: ReturnType | undefined; + +/** 페이지 사진첩 — 마지막에 세운 것(종단도 패널이 감싼 것으로 바꿔 둠). */ +let active: ProfileHistory | null = null; +export const pageHistory = (): ProfileHistory | null => active; +export function setPageHistory(history: ProfileHistory): void { + active = history; +} + +export type PageHistoryEvent = "undo" | "redo"; +interface PageHistoryHooks { + /** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */ + restore?: (kind: PageHistoryEvent) => void; + /** 쌓음 · 되돌림 뒤 — 단추 켜짐 맞춤. */ + change?: () => void; +} +const hooks = new Set(); +export function onPageHistory(entry: PageHistoryHooks): () => void { + hooks.add(entry); + return () => hooks.delete(entry); +} +const changed = (): void => hooks.forEach((entry) => entry.change?.()); + +/** 쌓는 줄이 따로 없는 조작도 놓은 뒤 한 걸음 — 같으면 안 쌓임. */ +function settle(): void { + clearTimeout(settleTimer); + settleTimer = setTimeout(() => active?.record(), SETTLE_MS); +} function watchPointer(): void { if (pointerWatched || typeof window === "undefined") return; @@ -80,11 +118,13 @@ function watchPointer(): void { const waiters = [...releaseWaiters]; releaseWaiters.clear(); waiters.forEach((fn) => fn()); + settle(); }, 0); }; window.addEventListener("pointerup", release, true); window.addEventListener("pointercancel", release, true); window.addEventListener("blur", release); + window.addEventListener("change", settle, true); // 창 밖에서 놓아 pointerup 을 못 받은 때 — 단추 없이 움직이면 놓은 것. window.addEventListener( "pointermove", @@ -142,14 +182,19 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil function apply(index: number): boolean { if (index < 0 || index >= stack.length) return false; + const kind: PageHistoryEvent = index < cursor ? "undo" : "redo"; cursor = index; restoring = true; restoreSnapshot(stack[cursor]); try { onRestore(); + hooks.forEach((entry) => entry.restore?.(kind)); + // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. + stack[cursor] = takeSnapshot(stack[cursor]); } finally { restoring = false; } + changed(); return true; } @@ -166,6 +211,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil stack.push(snapshot); if (stack.length > limit) stack.shift(); cursor = stack.length - 1; + changed(); }, pause() { paused = true; @@ -180,5 +226,6 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil canUndo: () => cursor > 0, canRedo: () => cursor < stack.length - 1, }; + active = history; return history; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index ecd2cee21..46f861f58 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -18,7 +18,11 @@ import type { } from "./B05_Profile_UI_Profile_Alignment"; import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; -import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; +import { + createProfileHistory, + setPageHistory, + type ProfileHistory, +} from "./B05_Profile_UI_Profile_History"; import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import { detectStraightRun, @@ -32,7 +36,6 @@ import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; -import { undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; @@ -117,6 +120,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { record: () => (syncPipes(), inner.record()), pause: () => (syncPipes(), inner.pause()), }; + setPageHistory(history); // 페이지 사진첩 하나 — 노선 편집기도 이것으로 쌓고 되돌림(346-4) // [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1). const free = createFreeDraw({ @@ -320,11 +324,11 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { freeView.redraw(); return; } - if (isCtrlKey(event, "z")) { - if (undoOwner() === "plan") return; // 평면(노선 편집)을 마지막에 만졌다(345-3). + // 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로. + if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) { event.preventDefault(); flushHold(); - if (event.shiftKey) history.redo(); + if (event.shiftKey || isCtrlKey(event, "y")) history.redo(); else history.undo(); return; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts index 1b08b9619..5c54fa6e1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -7,10 +7,19 @@ * 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은 * 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림 * (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫. + * + * 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 · [반영 전으로] 뒤 사진첩에 한 걸음 · + * [되돌리기] · [다시하기]는 사진첩이 함(평면 · 종단 · 횡단 · 관 · 구조물 한 줄) · 사진첩이 되쓴 노선 + * 초안은 `fromPage` 로 편집기 새 걸음이 됨. 사진첩이 없으면 편집기 사진 그대로. * ========================================================================== */ import type { RouteDraft } from "../A00_Common/design/design_pipeline_types"; -import type { RouteEditHistory, RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import { pageHistory } from "./B05_Profile_UI_Profile_History"; +import { + sameLine, + type RouteEditHistory, + type RouteEditSnapshot, +} from "./B05_Profile_UI_RouteEdit_History"; /** 편집기가 넘기는 노선 — 판 번호 · 지형은 다리가 붙임. */ export type RouteDraftEdit = Omit; @@ -59,15 +68,45 @@ export function watch( bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, stationSnap()) : null); return snapshot; }; + /** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */ + const fromPage = (): RouteEditSnapshot | null => { + const kept = bridge.saved(); + const opened = history.first?.(); + const line = kept?.nodes.length ? snapshotOfDraft(kept) : opened; + const now = history.current?.(); + if (!line || (now && sameLine(now, line))) return null; + const snapshot = history.match?.(line) ?? line; + history.commit(snapshot); + tell(snapshot); + return snapshot; + }; return { ...history, commit: (snapshot) => { history.commit(snapshot); tell(snapshot); + pageHistory()?.record(); }, - undo: () => tell(history.undo()), - redo: () => tell(history.redo()), - reset: () => tell(history.reset()), + undo: () => { + const album = pageHistory(); + if (!album) return tell(history.undo()); + album.undo(); + return null; // 편집기 되살림은 사진첩 restore → fromPage + }, + redo: () => { + const album = pageHistory(); + if (!album) return tell(history.redo()); + album.redo(); + return null; + }, + reset: () => { + const snapshot = tell(history.reset()); + if (snapshot) pageHistory()?.record(); + return snapshot; + }, + canUndo: () => pageHistory()?.canUndo() ?? history.canUndo(), + canRedo: () => pageHistory()?.canRedo() ?? history.canRedo(), + fromPage, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index d8c64130f..a9109d3c7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -8,10 +8,13 @@ * * 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로 * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. + * + * 되돌리기 줄은 페이지 사진첩 하나(`_Profile_History` · 346-4) — 계산 묶음 다리가 있으면(`_Draft.watch`) + * [되돌리기] · [다시하기] · Ctrl+Z · Y 는 사진첩이 받고, 여기 사진은 고정 · 추천 꼬리표 · 고른 점을 + * 되살리는 기록(`match`)과 [반영 전으로](`reset`) · 고친 것 있음(`isDirty`)에만 씀. * ========================================================================== */ -import { keyLetter } from "../A00_Common/key_shortcut"; -import { setPlanUndoListening, undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner"; +import { onPageHistory } from "./B05_Profile_UI_Profile_History"; export type Vertex = [number, number]; @@ -47,6 +50,13 @@ export interface RouteEditHistory { canRedo: () => boolean; /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ isDirty: () => boolean; + /** 창을 연 상태 · 지금 걸음(사본). */ + first?: () => RouteEditSnapshot; + current?: () => RouteEditSnapshot; + /** 같은 노선(노드 · 곡선)의 가장 최근 걸음 — 고정 · 추천 꼬리표 · 고른 점을 되살림. 없으면 null. */ + match?: (snapshot: RouteEditSnapshot) => RouteEditSnapshot | null; + /** 페이지 사진첩이 되쓴 노선 초안을 읽어 새 걸음으로 — 노선이 그대로면 null(`_Draft.watch`). */ + fromPage?: () => RouteEditSnapshot | null; } /** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */ @@ -63,8 +73,8 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { }; } -/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ -function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { +/** 두 사진이 같은 선(노드 · 곡선)인가 — 고정 · 고른 자리는 안 본다. */ +export function sameLine(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { if (a.planned.length !== b.planned.length) return false; for (let index = 0; index < a.planned.length; index += 1) { if (a.planned[index][0] !== b.planned[index][0]) return false; @@ -73,11 +83,15 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { if (a.curveRadius[index] !== b.curveRadius[index]) return false; if (a.curveLock[index] !== b.curveLock[index]) return false; if (a.curveArc[index] !== b.curveArc[index]) return false; - if (a.apexLock[index] !== b.apexLock[index]) return false; } return true; } +/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ +function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { + return sameLine(a, b) && a.apexLock.every((lock, index) => lock === b.apexLock[index]); +} + /** 첫 사진(창을 연 상태)으로 이력을 연다. */ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory { const steps: RouteEditSnapshot[] = [clone(initial)]; @@ -113,6 +127,14 @@ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHis canUndo: () => at > 0, canRedo: () => at < steps.length - 1, isDirty: () => !sameRoute(steps[at], steps[0]), + first: () => clone(steps[0]), + current: () => clone(steps[at]), + match(snapshot) { + for (let index = steps.length - 1; index >= 0; index -= 1) { + if (sameLine(steps[index], snapshot)) return clone(steps[index]); + } + return null; + }, }; } @@ -125,11 +147,11 @@ export interface HistoryControlsParams { restore: (snapshot: RouteEditSnapshot, message: string) => void; } -/** [되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. +/** [되돌리기]·[다시하기] 단추를 붙인다 — Ctrl+Z · Y 는 페이지 사진첩 하나가 받음(`_Profile_Panel_Tools`). * - * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. 단축키는 `listen(true)` 부터 - * 듣는다(보기가 보일 때 · 345-3) — 창(window)에 달았기 때문에 숨을 때 · 떠날 때 `listen(false)`. - * 종단도도 같은 키를 들어 **마지막에 만진 판**만 받는다(`_UndoOwner`). */ + * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다(사진첩이 바뀔 때도 스스로). + * 사진첩이 되돌린 걸음에 노선이 바뀌었으면 `restore` 로 편집기에 되살림. `listen` 은 옛 자리(보기 보임 · + * 숨김)를 위해 남김 — 단축키를 안 들으므로 할 일 없음. */ export function bindHistoryControls(params: HistoryControlsParams): { sync: () => void; listen: (on: boolean) => void; @@ -149,27 +171,20 @@ export function bindHistoryControls(params: HistoryControlsParams): { const history = getHistory(); if (!history) return; const snapshot = history[which](); - if (!snapshot) return; - restore(snapshot, which === "undo" ? "되돌렸습니다." : "다시 했습니다."); + if (snapshot) restore(snapshot, which === "undo" ? "되돌렸습니다." : "다시 했습니다."); sync(); }; undoBtn.addEventListener("click", () => step("undo")); redoBtn.addEventListener("click", () => step("redo")); - const onKey = (event: KeyboardEvent): void => { - if (!(event.ctrlKey || event.metaKey) || undoOwner() !== "plan") return; - const key = keyLetter(event); - if (key === "z" && !event.shiftKey) step("undo"); - else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); - else return; - event.preventDefault(); - }; - const listen = (on: boolean): void => { - window.removeEventListener("keydown", onKey); - if (on) window.addEventListener("keydown", onKey); - setPlanUndoListening(on); - }; + const off = onPageHistory({ + restore: (kind) => { + const snapshot = getHistory()?.fromPage?.(); + if (snapshot) restore(snapshot, kind === "undo" ? "되돌렸습니다." : "다시 했습니다."); + }, + change: sync, + }); - return { sync, listen, dispose: () => listen(false) }; + return { sync, listen: () => undefined, dispose: off }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts b/B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts deleted file mode 100644 index 77511517a..000000000 --- a/B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_RouteEdit_UndoOwner.ts - * 한 화면 두 이력(345-3) — 평면(노선 편집)과 종단도가 둘 다 창(window)에서 Ctrl+Z · Y 를 듣는다. - * **마지막에 만진 판 하나만** 받는다: 누른 자리(평면 `.b05-routeedit` · 종단 판 `.b05-route-profile`)를 - * 기억해 두고, 각 듣는 쪽이 받기 전에 묻는다. 평면이 안 듣는 동안(보기 숨김 · 떠남)은 늘 종단도. - * 한 줄 되돌리기로 합치는 일은 346-4. - * ========================================================================== */ - -export type UndoPanel = "plan" | "profile"; - -let last: UndoPanel = "profile"; -let planListening = false; -let bound = false; - -function remember(event: Event): void { - const target = event.target as Element | null; - if (!target?.closest) return; - if (target.closest(".b05-routeedit")) last = "plan"; - else if (target.closest(".b05-route-profile")) last = "profile"; -} - -/** 평면 이력이 듣기 시작 · 멈춤 — 보기가 보일 때 켜고 숨을 때 끈다. */ -export function setPlanUndoListening(on: boolean): void { - planListening = on; - if (bound) return; - bound = true; - window.addEventListener("pointerdown", remember, true); -} - -/** 지금 Ctrl+Z · Y 를 받을 판. */ -export function undoOwner(): UndoPanel { - return planListening && last === "plan" ? "plan" : "profile"; -} diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index 5e95ef67b..110850bb8 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -45,6 +45,7 @@ const STUBS = { canUndo: () => false, canRedo: () => false, }), + setPageHistory: () => undefined, }, "./B05_Profile_UI_Profile_Tools": { createProfileTools: () => ({ mode: () => "none", clear: () => undefined }), diff --git a/resources/tester/helper_345_3_undo_owner.cjs b/resources/tester/helper_345_3_undo_owner.cjs index 5bba50a51..2dd4a7671 100644 --- a/resources/tester/helper_345_3_undo_owner.cjs +++ b/resources/tester/helper_345_3_undo_owner.cjs @@ -1,6 +1,6 @@ -/* 345-3 Ctrl+Z · Y 한 판만 헬퍼 — `_RouteEdit_History`(평면 이력 단추 · 단축키)와 `_RouteEdit_UndoOwner` 를 - * 트랜스파일해 가짜 창(window)에서 돌림. 종단도 쪽 듣는 자리는 `_Profile_Panel_Tools` 와 같은 꼴 - * (`undoOwner() === "plan"` 이면 안 받음)로 흉내. 결과는 JSON 한 벌(표준출력). +/* 345-3 → 346-4 Ctrl+Z · Y 헬퍼 — 「마지막에 만진 판 하나」(`_RouteEdit_UndoOwner`)는 346-4 한 줄 되돌리기로 + * 없어짐. 평면 이력 단추(`_RouteEdit_History.bindHistoryControls`)가 단축키를 안 듣는지 가짜 창에서 봄 — + * 단축키는 종단도(`_Profile_Panel_Tools`)가 페이지 사진첩 하나로 받음. 결과는 JSON 한 벌(표준출력). */ const fs = require("fs"); const path = require("path"); @@ -33,7 +33,6 @@ function loadTs(file) { const page = path.join(ROOT, "B05_Profile"); const { bindHistoryControls } = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_History.ts")); -const { undoOwner } = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_UndoOwner.ts")); const button = () => ({ disabled: false, addEventListener: () => undefined }); const buttons = { '[data-act="undo"]': button(), '[data-act="redo"]': button() }; @@ -51,41 +50,19 @@ const controls = bindHistoryControls({ restore: () => undefined, }); -// 종단도 듣는 자리 흉내 — `_Profile_Panel_Tools` 의 Ctrl+Z 줄과 같은 거름. -const profile = []; -window.addEventListener("keydown", (event) => { - if (!(event.ctrlKey && event.key === "z")) return; - if (undoOwner() === "plan") return; - profile.push(event.shiftKey ? "redo" : "undo"); -}); - const key = (k, shift = false) => fire("keydown", { ctrlKey: true, key: k, code: "", shiftKey: shift, preventDefault() {} }); const press = (cls) => fire("pointerdown", { target: { closest: (sel) => (sel === `.${cls}` ? {} : null) } }); -const snap = () => ({ plan: [...plan], profile: [...profile], owner: undoOwner() }); const out = {}; -key("z"); // 듣기 전 — 종단도만 -out.beforeListen = snap(); controls.listen(true); -key("z"); // 아직 아무 판도 안 만짐 — 종단도 -out.untouched = snap(); press("b05-routeedit"); key("z"); key("y"); -out.afterPlanPress = snap(); -press("b05-route-profile"); -key("z"); -out.afterProfilePress = snap(); -press("b05-routeedit"); -controls.listen(false); // 보기 숨김 — 평면 이력은 안 들음 -key("z"); -out.hidden = snap(); -controls.listen(true); -key("z", true); // 다시 보임 · 마지막에 만진 판 = 평면 · Shift+Z = 다시하기 -out.shown = snap(); +key("z", true); +out.plan = [...plan]; +out.keydownListeners = listeners.keydown?.size ?? 0; +controls.listen(false); controls.dispose(); -key("z"); -out.disposed = snap(); console.log(JSON.stringify(out)); diff --git a/resources/tester/helper_346_4_one_undo.cjs b/resources/tester/helper_346_4_one_undo.cjs new file mode 100644 index 000000000..5471833af --- /dev/null +++ b/resources/tester/helper_346_4_one_undo.cjs @@ -0,0 +1,161 @@ +/* 346-4 되돌리기 한 줄 헬퍼 — 진짜 `_Profile_History`(페이지 사진첩) · `_RouteEdit_History`(편집기 단추) · + * `_RouteEdit_Draft`(다리 감싸기)를 트랜스파일해 가짜 창 · 세션에서 돌림. 다리 = 세션 칸 route-draft 를 + * 바로 쓰는 흉내(`design_pipeline_page` 와 같은 때). 결과 JSON 한 벌(표준출력). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const data = new Map(); +globalThis.sessionStorage = { + get length() { + return data.size; + }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +const win = new EventTarget(); +win.setTimeout = setTimeout; +win.clearTimeout = clearTimeout; +globalThis.window = win; + +const cache = new Map(); +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) => + request.startsWith(".") + ? loadTs(`${path.resolve(path.dirname(full), request)}.ts`) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const page = path.join(ROOT, "B05_Profile"); +const album = loadTs(path.join(page, "B05_Profile_UI_Profile_History.ts")); +const edit = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_History.ts")); +const draftLink = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_Draft.ts")); + +const P = "p1"; +const ROUTE = `aislo:draft:route-draft:${P}`; +const GRADE = `aislo:draft:alignment-draft:${P}:7`; +const CROSS = `aislo:draft:crossdesign:${P}:7`; +const SELECT = `aislo:draft:selection:${P}`; +const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); +const snap = (nodes, lock = false) => ({ + planned: nodes, + curveOn: nodes.map(() => true), + curveRadius: nodes.map(() => null), + curveLock: nodes.map(() => null), + curveArc: nodes.map(() => null), + apexLock: nodes.map((_, i) => lock && i === 1), + picked: 1, + rec: lock ? "tag-moved" : "tag-open", +}); + +(async () => { + const out = {}; + let profileReads = 0; + const pageAlbum = album.createProfileHistory(() => (profileReads += 1)); + const opened = snap([ + [0, 0], + [50, 0], + [100, 0], + ]); + const raw = edit.createRouteEditHistory(opened); + const bridge = { + saved: () => JSON.parse(sessionStorage.getItem(ROUTE) ?? "null"), + edited: (draft) => + draft + ? sessionStorage.setItem(ROUTE, JSON.stringify({ ...draft, rev: 1, terrain: null })) + : sessionStorage.removeItem(ROUTE), + }; + const history = draftLink.watch(raw, bridge, () => false); + const clicks = {}; + const button = (act) => ({ + disabled: false, + addEventListener: (_type, fn) => (clicks[act] = fn), + }); + const buttons = { '[data-act="undo"]': button("undo"), '[data-act="redo"]': button("redo") }; + const editor = []; // 편집기에 되살린 걸음(가운데 노드 x · 고정 · 꼬리표 · 글) + const controls = edit.bindHistoryControls({ + overlay: { querySelector: (sel) => buttons[sel] }, + getHistory: () => history, + restore: (s, message) => editor.push([s.planned[1][0], s.apexLock[1], s.rec, message]), + }); + const state = () => ({ + route: bridge.saved()?.nodes?.[1]?.[0] ?? null, + grade: sessionStorage.getItem(GRADE), + cross: sessionStorage.getItem(CROSS), + undo: !buttons['[data-act="undo"]'].disabled, + redo: !buttons['[data-act="redo"]'].disabled, + }); + controls.sync(); + out.start = state(); + + // ① 평면 점 옮김 — 끄는 동안 두 번 쌓아도 놓을 때 한 걸음 + win.dispatchEvent(new Event("pointerdown")); + history.commit( + snap([ + [0, 0], + [60, 5], + [100, 0], + ]), + ); + history.commit( + snap( + [ + [0, 0], + [70, 5], + [100, 0], + ], + true, + ), + ); + out.heldUndo = pageAlbum.canUndo(); + win.dispatchEvent(new Event("pointerup")); + await tick(); + // ② 계획고 ▲ — 종단도가 쌓음 + sessionStorage.setItem(GRADE, '{"station_offsets":{"20.000":0.1}}'); + pageAlbum.record(); + // 고름만 바뀜 — 사진 밖이라 걸음 안 늘어남 + sessionStorage.setItem(SELECT, '{"chainageM":40}'); + pageAlbum.record(); + // ③ 횡단 카드 고침 — 쌓는 줄 없음 · 칸 바꿈(change) 뒤 기다려 한 걸음 + sessionStorage.setItem(CROSS, '{"20.000":{"ground_type":"soil"}}'); + win.dispatchEvent(new Event("change")); + await tick(450); + out.afterEdits = state(); + + // Ctrl+Z 세 번 — 거꾸로(종단도 단축키 = 사진첩 undo) · 셋째는 편집기 [되돌리기] 단추 + pageAlbum.undo(); + out.undo1 = state(); + pageAlbum.undo(); + out.undo2 = state(); + out.editorBeforeRouteUndo = editor.length; + clicks.undo(); + out.undo3 = state(); + out.editorAfterUndo = [...editor]; + // 다시하기 세 번 — 첫째는 편집기 [다시하기] · 나머지는 Ctrl+Y + clicks.redo(); + out.redo1 = state(); + pageAlbum.redo(); + pageAlbum.redo(); + out.redo3 = state(); + out.editorAfterRedo = editor.slice(out.editorAfterUndo.length); + out.profileReads = profileReads; + // [반영 전으로] — 사진첩 한 걸음 · 되돌리면 옮긴 노선 + history.reset(); + out.reset = state(); + pageAlbum.undo(); + out.resetUndo = state(); + controls.dispose(); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_345_3_route_body.py b/resources/tester/test_345_3_route_body.py index 50efc25ba..2a038790d 100644 --- a/resources/tester/test_345_3_route_body.py +++ b/resources/tester/test_345_3_route_body.py @@ -1,7 +1,7 @@ """345-3 노선 편집 → 종단 보기 가운데 위 본문 — Ctrl+Z · Y 는 마지막에 만진 판 하나 · 종단도 판 흐름 배치 · 왼쪽 「계획노선」 칸 · 편집 단추 지움 · [노선 반영] 뒤 새로고침 없이 다시 세움. -Ctrl+Z 몫은 Node 헬퍼 `helper_345_3_undo_owner.cjs` 가 진짜 `_RouteEdit_History` · `_UndoOwner` 로 돌림 · +Ctrl+Z 몫은 Node 헬퍼 `helper_345_3_undo_owner.cjs` 가 진짜 `_RouteEdit_History` 로 돌림(346-4) · 배선 · CSS 는 글로 봄(화면 확인은 브레인). """ @@ -40,34 +40,17 @@ def out() -> dict: return json.loads(run.stdout) -def test_Ctrl_Z_는_마지막에_만진_판_하나(out): - assert out["beforeListen"] == {"plan": [], "profile": ["undo"], "owner": "profile"} - assert out["untouched"]["plan"] == [] and out["untouched"]["profile"] == ["undo", "undo"] - # 평면을 누른 뒤 — 평면만(종단도 안 움직임) - assert out["afterPlanPress"]["plan"] == ["undo", "redo"] - assert out["afterPlanPress"]["profile"] == ["undo", "undo"] - # 종단 판을 누른 뒤 — 종단도만 - assert out["afterProfilePress"]["plan"] == ["undo", "redo"] - assert out["afterProfilePress"]["profile"] == ["undo", "undo", "undo"] - # 보기 숨김(평면 안 들음) — 평면을 마지막에 만졌어도 종단도가 받음 - assert out["hidden"]["owner"] == "profile" - assert out["hidden"]["profile"] == ["undo", "undo", "undo", "undo"] - # 다시 보임 — 평면 · Shift+Z = 다시하기 - assert out["shown"]["plan"] == ["undo", "redo", "redo"] - assert out["shown"]["profile"] == out["hidden"]["profile"] - # 떠남 — 평면 단축키 뗌 - assert out["disposed"]["plan"] == out["shown"]["plan"] +def test_Ctrl_Z_는_사진첩_한_줄(out): + # 346-4 — 「마지막에 만진 판 하나」 없앰 · 평면 편집기는 단축키를 안 들음(종단도가 사진첩으로) + assert out["plan"] == [] and out["keydownListeners"] == 0 -def test_종단도_Ctrl_Z_줄이_평면을_묻는다(): +def test_종단도_Ctrl_Z_줄이_사진첩_하나(): tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") - at = tools.index('if (isCtrlKey(event, "z")) {') - assert 'if (undoOwner() === "plan") return;' in tools[at : at + 160] + at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {') + assert "undoOwner" not in tools and "history.undo()" in tools[at : at + 240] history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts") - assert 'undoOwner() !== "plan"' in history - assert ( - 'window.addEventListener("keydown", onKey);\n\n return' not in history - ) # 만들 때 안 붙음 + assert '"keydown"' not in history and "onPageHistory" in history def test_편집기는_본문_페이지_수명(): diff --git a/resources/tester/test_346_4_one_undo.py b/resources/tester/test_346_4_one_undo.py new file mode 100644 index 000000000..2fa120d9a --- /dev/null +++ b/resources/tester/test_346_4_one_undo.py @@ -0,0 +1,68 @@ +"""346-4 되돌리기 한 줄 — 평면 점 옮김 → 계획고 ▲ → 횡단 카드 고침 → Ctrl+Z 세 번이 거꾸로. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_one_undo.py -q + +사진 크기(300 KB 칸 셋 × 100단 heap)는 `test_346_4_profile_history.py`. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_346_4_one_undo.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_끄는_중간은_안_찍고_놓을_때_한_걸음(out) -> None: + assert out["start"]["undo"] is False + assert out["heldUndo"] is False + edited = out["afterEdits"] + assert edited["route"] == 70 and edited["grade"] and edited["cross"] and edited["undo"] + + +def test_Ctrl_Z_세_번이_거꾸로(out) -> None: + u1, u2, u3 = out["undo1"], out["undo2"], out["undo3"] + assert u1["cross"] is None and u1["grade"] and u1["route"] == 70 # 횡단부터 + assert u2["grade"] is None and u2["route"] == 70 # 그다음 계획고 + assert out["editorBeforeRouteUndo"] == 0 # 노선이 그대로인 걸음은 편집기를 안 건드림 + assert u3["route"] is None and u3["undo"] is False and u3["redo"] # 마지막 평면 · 열 때로 + assert out["editorAfterUndo"] == [[50, False, "tag-open", "되돌렸습니다."]] + + +def test_다시하기는_앞으로(out) -> None: + assert out["redo1"]["route"] == 70 and out["redo1"]["grade"] is None + r3 = out["redo3"] + assert r3["route"] == 70 and r3["grade"] and r3["cross"] and r3["redo"] is False + # 옮긴 걸음의 고정 · 추천 꼬리표까지 되살림(같은 선의 옛 걸음) + assert out["editorAfterRedo"] == [[70, True, "tag-moved", "다시 했습니다."]] + assert out["profileReads"] == 6 # 종단도도 걸음마다 다시 읽음 + + +def test_반영_전으로도_한_걸음(out) -> None: + assert out["reset"]["route"] is None and out["reset"]["cross"] # 노선만 열 때로 + assert out["resetUndo"]["route"] == 70 + + +def test_단축키는_사진첩_한_곳(): + tools = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts").read_text(encoding="utf-8") + assert "undoOwner" not in tools + assert 'isCtrlKey(event, "y")' in tools and "setPageHistory(history)" in tools + plan = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_History.ts").read_text(encoding="utf-8") + assert '"keydown"' not in plan + assert not (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts").exists()