diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index d7c21b5f7..7c095e5fb 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -21,6 +21,8 @@ * 놓은 뒤 · 칸 값 바꾼 뒤(change) 잠깐 기다려 한 번 더 찍음 — 쌓는 줄이 따로 없는 조작(횡단 카드 등)도 한 걸음. * 열 때(349) — 세운 뒤 첫 손길(누름 · 키 · 칸 바꿈) 전까지는 찍어도 첫 사진을 다시 뜨기만 함: 열 때 쓰는 * 초안(표준 횡단면 · 첫 계산 결과 · 되살린 노선 초안)은 걸음이 아니라 열 때 모습. + * 쉬는 동안 온 값(364-1) — 손길이 다 찍힌 뒤 새 손길이 닿기 전에 바뀐 칸(뒤에서 오는 파생 값 · 찍을 때만 + * 옮겨 적는 관 자리)은 그 손길 직전에 지금 사진으로 덮음 — 판 열기 · 회전 같은 보기 손길이 걸음이 안 됨. * ========================================================================== */ /** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ @@ -79,11 +81,15 @@ let pointerHeld = false; const releaseWaiters = new Set<() => void>(); let pointerWatched = false; let settleTimer: ReturnType | undefined; +/** 놓은 뒤 · 칸 바꾼 뒤 찍기를 기다리는 중 — 그 사이 손길은 앞 조작의 값을 덮으면 안 됨. */ +let settling = false; /** 사용자 손길 수 — 사진첩마다 세운 때 값과 견줘 「열 때」를 가름. */ let gestures = 0; /** 페이지 사진첩 — 마지막에 세운 것(종단도 패널이 감싼 것으로 바꿔 둠). */ let active: ProfileHistory | null = null; +/** 마지막에 세운 사진첩의 「쉬는 동안 온 값 덮기」 — 손길이 닿을 때 부름(364-1). */ +let rebaseActive: (() => void) | null = null; export const pageHistory = (): ProfileHistory | null => active; export function setPageHistory(history: ProfileHistory): void { active = history; @@ -110,15 +116,22 @@ const changed = (): void => hooks.forEach((entry) => entry.change?.()); /** 쌓는 줄이 따로 없는 조작도 놓은 뒤 한 걸음 — 같으면 안 쌓임. */ function settle(): void { clearTimeout(settleTimer); - settleTimer = setTimeout(() => active?.record(), SETTLE_MS); + settling = true; + settleTimer = setTimeout(() => { + settling = false; + active?.record(); + }, SETTLE_MS); } function watchPointer(): void { if (pointerWatched || typeof window === "undefined") return; if (typeof window.addEventListener !== "function") return; pointerWatched = true; - const touched = (): void => void (gestures += 1); - window.addEventListener("pointerdown", () => ((pointerHeld = true), touched()), true); + const touched = (): void => { + rebaseActive?.(); + gestures += 1; + }; + window.addEventListener("pointerdown", () => (touched(), (pointerHeld = true)), true); window.addEventListener("keydown", touched, true); window.addEventListener("change", touched, true); const release = (): void => { @@ -182,8 +195,14 @@ export interface ProfileHistory { * `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로 * 여기서 각 패널이 자기 값을 다시 읽어 그린다. */ -export function createProfileHistory(onRestore: () => void, limit = 100): ProfileHistory { +export function createProfileHistory( + onRestore: () => void, + limit = 100, + /** 찍기 직전 — 세션 밖 값을 사진이 보는 칸으로 옮겨 적음(관 자리). */ + sync: () => void = () => undefined, +): ProfileHistory { watchPointer(); + sync(); const stack: HistorySnapshot[] = [takeSnapshot()]; let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ @@ -192,6 +211,8 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil let paused = false; /** 세운 때 손길 수 — 손길을 못 듣는 환경(창 없음)은 열 때가 없음. */ const openedAt = pointerWatched ? gestures : -1; + /** 마지막으로 찍은 때 손길 수 — 같으면 그 뒤 손길이 없었음(쉬는 중). */ + let restAt = gestures; function apply(index: number): boolean { if (index < 0 || index >= stack.length) return false; @@ -207,6 +228,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil } finally { restoring = replaying = false; } + restAt = gestures; changed(); return true; } @@ -218,6 +240,8 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil releaseWaiters.add(history.record); return; } + sync(); + restAt = gestures; const snapshot = takeSnapshot(stack[cursor]); if (sameSnapshot(snapshot, stack[cursor])) return; if (gestures === openedAt) { @@ -243,6 +267,11 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil canUndo: () => cursor > 0, canRedo: () => cursor < stack.length - 1, }; + rebaseActive = () => { + if (restoring || paused || pointerHeld || settling || gestures !== restAt) return; + sync(); + stack[cursor] = takeSnapshot(stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습 + }; 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 8d173cd0f..12bc11376 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -108,18 +108,18 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { } // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). - const inner = createProfileHistory(() => { - ctx.restore(); - restorePipes(); - }); - /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. + /** 찍기 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다(세울 때 · 쉬는 동안 온 값 덮을 때도). * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ - const history: ProfileHistory = { - ...inner, - record: () => (syncPipes(), inner.record()), - pause: () => (syncPipes(), inner.pause()), - }; + const inner = createProfileHistory( + () => { + ctx.restore(); + restorePipes(); + }, + 100, + syncPipes, + ); + const history: ProfileHistory = { ...inner, pause: () => (syncPipes(), inner.pause()) }; setPageHistory(history); // 페이지 사진첩 하나 — 노선 편집기도 이것으로 쌓고 되돌림(346-4) // [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1). diff --git a/resources/tester/test_364_1_view_no_step.py b/resources/tester/test_364_1_view_no_step.py new file mode 100644 index 000000000..286e71fb8 --- /dev/null +++ b/resources/tester/test_364_1_view_no_step.py @@ -0,0 +1,159 @@ +"""364-1 보기 손길 · 쉬는 동안 온 값은 되돌리기 걸음 아님 — 설계 값이 바뀐 때만 걸음. + +· 새 페이지 → 뒤에서 온 값(관 자리 불러옴 · 표준 횡단 · 따라간 구조물) 뒤 판 열기 · 회전 · 맞춤 · 미리보기 · 도움말 = canUndo false +· 꺾임점 끌기 · 계획고 ▲ = 걸음 하나씩 · 되돌리기 한 걸음 = 그 편집만 +· 막이 — 놓은 뒤 기다리는 사이 손길 · 글 치는 중 다른 곳 누름은 앞 편집을 덮지 않음 + +./venv/Scripts/python.exe -m pytest resources/tester/test_364_1_view_no_step.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +DRIVER = r""" +import { writeFileSync } from "node:fs"; +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), +}; +globalThis.window = new EventTarget(); +window.setTimeout = setTimeout; +const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js"); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const fire = (type) => window.dispatchEvent(new Event(type)); +/** 누르고 놓음 — 보기 손길(세션 안 씀) 또는 편집(누른 동안 write). */ +async function click(write) { + fire("pointerdown"); + write?.(); + fire("pointerup"); + await wait(450); +} +const K = { + route: "aislo:draft:route-draft:p1", + grade: "aislo:draft:alignment-draft:p1:7", + std: "aislo:draft:std-cross:p1", + structures: "aislo:draft:structures:p1", + cross: "aislo:draft:crossdesign:p1:7", + pipes: "b05:pipes", +}; +const out = {}; + +// 새 페이지 — 관 자리는 찍을 때만 옮겨 적음(Panel_Tools syncPipes 꼴) +let pipes = []; +sessionStorage.setItem(K.route, "r0"); +const h = createProfileHistory(() => {}, 100, () => sessionStorage.setItem(K.pipes, JSON.stringify(pipes))); +h.record(); // 열 때 찍기 +// 손길 없이 뒤에서 옴 — 배수 판이 관 자리 불러옴 · 표준 횡단 · 따라간 구조물 +pipes = [10, 20]; +sessionStorage.setItem(K.std, "std"); +sessionStorage.setItem(K.structures, "s0"); +await click(); // [횡단] 판 열기 +out.sideOpen = h.canUndo(); +sessionStorage.setItem(K.structures, "s1"); // 또 뒤에서 옴(파생 값) +await click(); // 회전 +fire("keydown"); // 도움말 · 맞춤 단축키 +await wait(450); +await click(); // 측점 눈금 횡단 미리보기 +await click(); // 판 닫기 +out.views = h.canUndo(); + +// 꺾임점 끌기 — 누른 동안 여러 번 찍어도 놓을 때 한 걸음 +fire("pointerdown"); +for (const v of ["r1", "r2", "r3"]) { + sessionStorage.setItem(K.route, v); + h.record(); +} +fire("pointerup"); +await wait(450); +out.drag = h.canUndo(); +sessionStorage.setItem(K.structures, "s2"); // 끈 노선 따라 구조물 다시 섬(뒤에서) +await click(); // 맞춤 +// 계획고 ▲ +await click(() => sessionStorage.setItem(K.grade, "g1")); +out.gradeUndo = h.undo(); +out.afterGradeUndo = { route: sessionStorage.getItem(K.route), grade: sessionStorage.getItem(K.grade), structures: sessionStorage.getItem(K.structures) }; +out.dragUndo = h.undo(); +out.afterDragUndo = { route: sessionStorage.getItem(K.route), structures: sessionStorage.getItem(K.structures), pipes: sessionStorage.getItem(K.pipes), std: sessionStorage.getItem(K.std) }; +out.noMore = !h.canUndo(); + +// 막이 1 — 놓은 뒤 기다리는 사이(400 ms) 다른 곳 누름은 앞 편집을 덮지 않음 +data.clear(); +const h2 = createProfileHistory(() => {}); +fire("pointerdown"); +h2.record(); // 누른 동안 찍기 → 놓을 때 찍힘(바뀐 것 없음) +fire("pointerup"); +await wait(0); +sessionStorage.setItem(K.cross, "c1"); // 카드 단추 — 놓은 뒤 씀 · 찍는 줄 없음 +await wait(50); +await click(); // 곧바로 판 열기 +out.settleKept = h2.canUndo(); + +// 막이 2 — 글 치는 중(키마다 씀) 다른 곳 누름 = 앞 편집 안 덮음 +data.clear(); +const h3 = createProfileHistory(() => {}); +fire("keydown"); +sessionStorage.setItem(K.grade, "typed"); +await wait(450); +await click(); // 칸 밖 누름 +fire("change"); +await wait(450); +out.typedKept = h3.canUndo(); +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def result(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_364_1") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_보기_손길과_뒤에서_온_값은_걸음_아님(result) -> None: + assert result["sideOpen"] is False + assert result["views"] is False + + +def test_편집만_걸음_되돌리기는_그_편집만(result) -> None: + assert result["drag"] is True + assert result["gradeUndo"] is True + # 계획고만 되돌아감 — 끌기 · 뒤에서 온 구조물은 그대로 + assert result["afterGradeUndo"] == {"route": "r3", "grade": None, "structures": "s2"} + assert result["dragUndo"] is True + # 끌기 전 모습 = 열고 쉬는 동안 온 값까지(그건 걸음이 아님) + assert result["afterDragUndo"] == { + "route": "r0", + "structures": "s1", + "pipes": "[10,20]", + "std": "std", + } + assert result["noMore"] is True + + +def test_앞_편집은_덮지_않음(result) -> None: + assert result["settleKept"] is True + assert result["typedKept"] is True diff --git a/resources/tester/test_app_settings.py b/resources/tester/test_app_settings.py index 9016b0b37..1bc7f5011 100644 --- a/resources/tester/test_app_settings.py +++ b/resources/tester/test_app_settings.py @@ -96,6 +96,22 @@ def _close(page): page.click(".app-settings .ui-modal__top > button") +def test_다른_곳_단축키_줄마다_동작_있음(): + """364-2 — [다른 곳 단축키] 모든 줄 = 「입력 = 동작」 · ko · en 줄 수 같음.""" + import re + + src = open( + os.path.join(ROOT, "ui_template", "ui_template_locale_common.ts"), encoding="utf8" + ).read() + blocks = re.findall(r'App_Settings_Keys_\w+_Keys: \[\s*"(.*?)",\s*"(.*?)",\s*\]', src, re.S) + assert len(blocks) == 7 + for ko, en in blocks: + ko_lines, en_lines = ko.split("\\n"), en.split("\\n") + assert len(ko_lines) == len(en_lines), ko + for line in ko_lines + en_lines: + assert " = " in line and not line.rstrip().endswith(" ="), line + + @pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") def test_설정_창_탭_저장_물음(): from playwright.sync_api import sync_playwright diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 7a5e3a990..ddad8eb97 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -150,8 +150,8 @@ export const ui_locales_common = { ], App_Settings_Keys_Profile: ["B05 종단", "B05 profile"], App_Settings_Keys_Profile_Keys: [ - "Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc · Backspace", - "Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc · Backspace", + "Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc(자유지정) = 취소\nBackspace(자유지정) = 마지막 점 빼기", + "Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc (free mode) = cancel\nBackspace (free mode) = remove last point", ], App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"], App_Settings_Keys_RouteEdit_Keys: [ @@ -160,13 +160,13 @@ export const ui_locales_common = { ], App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], App_Settings_Keys_Section_Keys: [ - "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc", - "Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc", + "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc = 고름 풀기\nEsc(상세 창 고르기 중) = 고르기 취소", + "Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc = clear selection\nEsc (detail window, while picking) = cancel picking", ], App_Settings_Keys_Cad: ["CAD", "CAD"], App_Settings_Keys_Cad_Keys: [ - "F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →", - "F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →", + "F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A = 모두 고름\nDelete = 고른 것 지움\nEsc = 명령 취소 · 쉴 때 고름 풀기\nEnter = 입력 끝\nCtrl+C = 복사\nCtrl+X = 잘라 냄\nCtrl+V = 붙여넣기\nCtrl+1 = 특성\n← → = 화면 옮김", + "F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A = select all\nDelete = delete selection\nEsc = cancel command · clear selection when idle\nEnter = submit input\nCtrl+C = copy\nCtrl+X = cut\nCtrl+V = paste\nCtrl+1 = properties\n← → = pan", ], App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"], App_Settings_Keys_Table_Keys: [ @@ -175,8 +175,8 @@ export const ui_locales_common = { ], App_Settings_Keys_M02: ["M02 양식", "M02 templates"], App_Settings_Keys_M02_Keys: [ - "Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C · Ctrl+V · Delete · Esc", - "Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C · Ctrl+V · Delete · Esc", + "Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C(구조물 나무) = 복사\nCtrl+V(구조물 나무) = 붙여넣기\nDelete = 고른 항목 지움\nEsc = 연결 · 열린 창 취소", + "Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C (structure tree) = copy\nCtrl+V (structure tree) = paste\nDelete = delete selected item\nEsc = cancel link · close open window", ], App_Footer_Copyright: [ "© 2026 임도설계 및 견적 자동화. All rights reserved.",