fix(되돌리기): 364-1 보기 손길 · 쉬는 동안 온 값은 되돌리기 걸음 아님
- 손길이 닿기 직전, 쉬는 중(마지막 찍기 뒤 손길 없음 · 놓은 뒤 기다림 아님)이면 지금 걸음 사진을 지금 세션으로 덮음 - 관 자리 옮겨 적기(syncPipes)를 사진첩 sync 로 — 세울 때 · 찍을 때 · 덮을 때 모두 - 판 열기 · 회전 · 맞춤 · 미리보기 뒤 canUndo false · 끌기 · 계획고 ▲ 만 걸음(test_364_1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
@@ -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<typeof setTimeout> | 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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user