feat(B05 되돌리기): 346-4 앞일 — 사진 대상 aislo:draft: 전부 · 누름 중간 안 찍음 · 사진끼리 같은 글 한 벌
- 대상 = 초안 통 전부(노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(보기 설정 뺌) · pref · result 밖 - 누름(마우스 · 터치 · 펜)이 남아 있으면 record() 미룸 → 놓은 뒤 한 틱에 한 번 · 창 밖에서 놓음도 처리 - 안 바뀐 칸은 앞 사진 글을 같이 씀 — 300 KB 칸 셋 × 100단 heap 89 MB → 10 MB 안 · 한도 50 → 100 - 시험 test_346_4_profile_history.py · 노선 편집 이력 합치기는 3파 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -10,10 +10,15 @@
|
||||
*
|
||||
* 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라
|
||||
* 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`).
|
||||
*
|
||||
* 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4).
|
||||
* 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번.
|
||||
* 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
|
||||
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-", "aislo:draft:alignment-draft:"];
|
||||
const DRAFT_PREFIX = "aislo:draft:";
|
||||
const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
|
||||
|
||||
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
|
||||
function isLayoutKey(key: string): boolean {
|
||||
@@ -27,20 +32,22 @@ function isLayoutKey(key: string): boolean {
|
||||
}
|
||||
|
||||
function isDataKey(key: string): boolean {
|
||||
return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
|
||||
if (key.startsWith(DRAFT_PREFIX)) return true;
|
||||
return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
|
||||
}
|
||||
|
||||
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
|
||||
export type HistorySnapshot = Record<string, string>;
|
||||
|
||||
function takeSnapshot(): HistorySnapshot {
|
||||
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */
|
||||
function takeSnapshot(previous?: HistorySnapshot): HistorySnapshot {
|
||||
const snapshot: HistorySnapshot = {};
|
||||
try {
|
||||
for (let index = 0; index < sessionStorage.length; index += 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (!key || !isDataKey(key)) continue;
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (value !== null) snapshot[key] = value;
|
||||
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
|
||||
}
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다.
|
||||
@@ -55,6 +62,39 @@ function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean {
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
/** 누름(마우스 · 터치 · 펜)이 남아 있나 — 창 하나에 한 번 듣고 놓으면 미룬 기록을 부름. */
|
||||
let pointerHeld = false;
|
||||
const releaseWaiters = new Set<() => void>();
|
||||
let pointerWatched = false;
|
||||
|
||||
function watchPointer(): void {
|
||||
if (pointerWatched || typeof window === "undefined") return;
|
||||
if (typeof window.addEventListener !== "function") return;
|
||||
pointerWatched = true;
|
||||
window.addEventListener("pointerdown", () => (pointerHeld = true), true);
|
||||
const release = (): void => {
|
||||
if (!pointerHeld) return;
|
||||
// 놓을 때 부르는 쪽(끌기 마무리 · 세션 쓰기)이 먼저 끝나게 한 틱 뒤.
|
||||
window.setTimeout(() => {
|
||||
pointerHeld = false;
|
||||
const waiters = [...releaseWaiters];
|
||||
releaseWaiters.clear();
|
||||
waiters.forEach((fn) => fn());
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener("pointerup", release, true);
|
||||
window.addEventListener("pointercancel", release, true);
|
||||
window.addEventListener("blur", release);
|
||||
// 창 밖에서 놓아 pointerup 을 못 받은 때 — 단추 없이 움직이면 놓은 것.
|
||||
window.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (pointerHeld && event.buttons === 0) release();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */
|
||||
function restoreSnapshot(snapshot: HistorySnapshot): void {
|
||||
try {
|
||||
@@ -91,7 +131,8 @@ export interface ProfileHistory {
|
||||
* `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로
|
||||
* 여기서 각 패널이 자기 값을 다시 읽어 그린다.
|
||||
*/
|
||||
export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory {
|
||||
export function createProfileHistory(onRestore: () => void, limit = 100): ProfileHistory {
|
||||
watchPointer();
|
||||
const stack: HistorySnapshot[] = [takeSnapshot()];
|
||||
let cursor = 0;
|
||||
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
|
||||
@@ -112,10 +153,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
const history: ProfileHistory = {
|
||||
record() {
|
||||
if (restoring || paused) return;
|
||||
const snapshot = takeSnapshot();
|
||||
if (pointerHeld) {
|
||||
releaseWaiters.add(history.record);
|
||||
return;
|
||||
}
|
||||
const snapshot = takeSnapshot(stack[cursor]);
|
||||
if (sameSnapshot(snapshot, stack[cursor])) return;
|
||||
stack.splice(cursor + 1);
|
||||
stack.push(snapshot);
|
||||
@@ -128,11 +173,12 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
resume() {
|
||||
if (!paused) return;
|
||||
paused = false;
|
||||
this.record();
|
||||
history.record();
|
||||
},
|
||||
undo: () => apply(cursor - 1),
|
||||
redo: () => apply(cursor + 1),
|
||||
canUndo: () => cursor > 0,
|
||||
canRedo: () => cursor < stack.length - 1,
|
||||
};
|
||||
return history;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_profile_history.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";
|
||||
// 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고).
|
||||
const data = new Map();
|
||||
globalThis.sessionStorage = {
|
||||
get length() { return data.size; },
|
||||
key: (i) => [...data.keys()][i] ?? null,
|
||||
getItem: (k) => (data.has(k) ? Buffer.from(data.get(k)).toString() : 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 tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
const out = {};
|
||||
|
||||
// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌
|
||||
const K = {
|
||||
route: "aislo:draft:route-draft:p1",
|
||||
cross: "aislo:draft:crossdesign:p1:7",
|
||||
pipes: "aislo:draft:pipes:p1",
|
||||
legacy: "b05:uphill:p1",
|
||||
layout: "b05-panel-collapsed",
|
||||
pref: "aislo:pref:table-open",
|
||||
result: "aislo:result:latest:p1",
|
||||
};
|
||||
for (const k of Object.values(K)) sessionStorage.setItem(k, "0");
|
||||
let restored = 0;
|
||||
const h = createProfileHistory(() => (restored += 1));
|
||||
for (const k of Object.values(K)) sessionStorage.setItem(k, "1");
|
||||
h.record();
|
||||
out.undo = h.undo();
|
||||
out.after = Object.fromEntries(Object.entries(K).map(([n, k]) => [n, sessionStorage.getItem(k)]));
|
||||
out.restored = restored;
|
||||
|
||||
// 2. 누르고 있는 동안 기록 미룸 · 놓으면 한 번
|
||||
data.clear();
|
||||
sessionStorage.setItem(K.cross, "a");
|
||||
const h2 = createProfileHistory(() => {});
|
||||
window.dispatchEvent(new Event("pointerdown"));
|
||||
for (const v of ["b", "c", "d", "e"]) {
|
||||
sessionStorage.setItem(K.cross, v);
|
||||
h2.record();
|
||||
}
|
||||
out.heldCanUndo = h2.canUndo();
|
||||
window.dispatchEvent(new Event("pointerup"));
|
||||
await tick();
|
||||
out.releasedCanUndo = h2.canUndo();
|
||||
h2.undo();
|
||||
out.oneUndo = sessionStorage.getItem(K.cross);
|
||||
out.noMoreUndo = !h2.canUndo();
|
||||
|
||||
// 3. 크기 — 300 KB 칸 셋 · 작은 칸만 바꾸며 100단
|
||||
data.clear();
|
||||
const big = (c) => c.repeat(300 * 1024);
|
||||
sessionStorage.setItem(K.route, big("r"));
|
||||
sessionStorage.setItem(K.cross, big("x"));
|
||||
sessionStorage.setItem(K.pipes, big("p"));
|
||||
globalThis.gc();
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
const h3 = createProfileHistory(() => {});
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
sessionStorage.setItem("aislo:draft:alignment-draft:p1:7", JSON.stringify({ i }));
|
||||
h3.record();
|
||||
}
|
||||
globalThis.gc();
|
||||
out.heapMb = (process.memoryUsage().heapUsed - before) / 1024 / 1024;
|
||||
let steps = 0;
|
||||
while (h3.undo()) steps += 1;
|
||||
out.steps = steps;
|
||||
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_history")
|
||||
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", "--expose-gc", 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:
|
||||
after = result["after"]
|
||||
assert result["undo"] and result["restored"] == 1
|
||||
assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0"
|
||||
# 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림
|
||||
assert after["layout"] == after["pref"] == after["result"] == "1"
|
||||
|
||||
|
||||
def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None:
|
||||
assert result["heldCanUndo"] is False
|
||||
assert result["releasedCanUndo"] is True
|
||||
assert result["oneUndo"] == "a" and result["noMoreUndo"]
|
||||
|
||||
|
||||
def test_큰_칸_100단도_바뀐_칸만_늚(result) -> None:
|
||||
assert result["steps"] == 99 # 한도 100(첫 사진 포함)
|
||||
# 같은 글을 같이 쓰지 않으면 900 KB × 100 ≈ 90 MB(UTF-8 1바이트 글 기준 · 실제는 더)
|
||||
assert result["heapMb"] < 10, result["heapMb"]
|
||||
Reference in New Issue
Block a user