Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-16 11:52:45 +09:00
8 changed files with 535 additions and 9 deletions
+123
View File
@@ -0,0 +1,123 @@
/* =============================================================================
* A00_Common/design/design_leave.ts
* B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때
* 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]).
*
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) ·
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
* 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄:
* [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음.
* B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음.
* 프로젝트 바꿈은 이 페이지를 떠난 뒤라 이 물음이 먼저 · 들어갈 때 물음은 `b_project_switch`.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import {
clearState,
namesInBucket,
readByKey,
routeStateKeys,
stateKey,
type StateName,
} from "../b_page_state";
import { currentRoute, setLeaveGuard } from "../router";
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */
const NOT_EDITS = new Set<StateName>([
"selection",
"pipe-ids",
"structure-pick",
"structure-type-cache",
"std-cross",
]);
const OTHER_PAGE = /^b0[89]-/;
export function editDraftNames(): StateName[] {
return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name));
}
const filled = (raw: string | null): raw is string =>
raw !== null && raw !== "" && raw !== "{}" && raw !== "[]";
/** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */
export function editSignature(projectId: string): string {
const parts: string[] = [];
for (const name of editDraftNames()) {
const own = stateKey(name, projectId);
const keys = own ? [own] : routeStateKeys(name, projectId);
for (const key of keys.sort()) {
const raw = readByKey(key);
if (filled(raw)) parts.push(`${key}=${raw}`);
}
}
return parts.join("\n");
}
/** 긴 지문을 들고 있지 않게 — FNV-1a 32비트 + 길이. */
function digest(text: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `${text.length}:${hash >>> 0}`;
}
// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음.
const cleanMarks = new Map<string, string>();
/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */
export function markDesignClean(projectId: string): void {
if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId)));
}
/** 저장 안 한 조작이 있나. */
export function designDirty(projectId: string): boolean {
if (!projectId) return false;
const signature = editSignature(projectId);
if (!signature) return false;
return cleanMarks.get(projectId) !== digest(signature);
}
/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */
export function discardDesignEdits(projectId: string): void {
editDraftNames().forEach((name) => clearState(name, projectId));
}
const SAME_PAGE = new Set<string>([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]);
/**
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂.
* `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`).
*/
export function guardDesignLeave(
projectId: string,
root: HTMLElement,
save: () => Promise<unknown>,
): void {
const unload = (event: BeforeUnloadEvent): void => {
if (!root.isConnected) return window.removeEventListener("beforeunload", unload);
if (!designDirty(projectId)) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", unload);
const guard = async (): Promise<boolean> => {
if (!root.isConnected) return true;
if (SAME_PAGE.has(currentRoute())) {
// 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺.
window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0);
return true;
}
if (!designDirty(projectId)) return true;
const got = await leaveOrStay(L("B05_Design_Leave"), {
save,
stillDirty: () => designDirty(projectId),
discard: () => discardDesignEdits(projectId),
});
return got !== "stay";
};
setLeaveGuard(guard);
}
+3 -1
View File
@@ -28,7 +28,8 @@ import {
} from "../b_workflow_nav";
import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock";
import type { DesignPageShared } from "./design_shared";
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import "./design_page.css";
@@ -138,6 +139,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
revealLeft: () => layout?.setOptionsOpen(true),
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
};
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
const applyView = (view: DesignView): void => {
+2
View File
@@ -26,6 +26,7 @@ import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
@@ -313,6 +314,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
else await profileSave?.cross?.(isConfirm);
},
done: async (isConfirm) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
if (isConfirm) {
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
+54 -8
View File
@@ -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,167 @@
/* 346-4 떠남 확인 단위검증 헬퍼 — `A00_Common/design/design_leave.ts` 를 그 자리에서 트랜스파일 ·
* `b_page_state` 는 진짜 · 라우터 · 물음 창 · 문구는 흉내. 결과 JSON 을 stdout 으로.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
clear: () => map.clear(),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
}
const session = memoryStorage();
const win = new EventTarget();
win.sessionStorage = session;
win.localStorage = memoryStorage();
win.setTimeout = setTimeout;
globalThis.window = win;
globalThis.sessionStorage = session;
let route = "/b05-profile";
let guard = null;
let guardSets = 0;
let choice = "cancel";
const asked = [];
const STUBS = {
"@config/config_frontend": {
ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" },
},
"@ui/ui_template_locale": { t: (key) => key },
"@ui/ui_template_leave_dialog": {
leaveOrStay: async (message, hooks) => {
asked.push(message);
if (choice === "cancel") return "stay";
if (choice === "discard") {
await hooks.discard();
return "discarded";
}
await hooks.save();
return hooks.stillDirty() ? "stay" : "saved";
},
},
"../router": {
currentRoute: () => route,
setLeaveGuard: (fn) => {
guard = fn;
guardSets += 1;
},
},
};
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (request in STUBS) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
const P = "p1";
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */
class BeforeUnload extends Event {
returnValue = true;
}
const unloadBlocked = () => {
const event = new BeforeUnload("beforeunload", { cancelable: true });
win.dispatchEvent(event);
return event.defaultPrevented;
};
(async () => {
const out = {};
const root = { isConnected: true };
let saveMarks = true;
let saves = 0;
leave.guardDesignLeave(P, root, async () => {
saves += 1;
if (saveMarks) leave.markDesignClean(P);
});
out.emptyDirty = leave.designDirty(P);
// 고침이 아닌 칸 · 다른 페이지 칸 · 표준 횡단면만 — 안 물음
session.setItem(`aislo:draft:selection:${P}`, '{"chainageM":20}');
session.setItem(`aislo:draft:std-cross:${P}`, '{"soil":{}}');
session.setItem(`aislo:draft:pipe-ids:${P}`, '{"next":3}');
session.setItem(`aislo:draft:b08-materials:${P}`, '{"units":1}');
session.setItem(`aislo:draft:crossdesign:${P}:7`, "{}"); // 빈 칸
out.nonEditDirty = leave.designDirty(P);
out.nonEditUnload = unloadBlocked();
route = "/b08-quantity";
out.nonEditLeave = await guard();
out.askedNonEdit = asked.length;
// 횡단 초안(노선 7) — 물음
session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"20.000":{"ground_type":"soil"}}');
out.editDirty = leave.designDirty(P);
out.editUnload = unloadBlocked();
// 같은 페이지(B05 → B06) — 안 묻고 한 틱 뒤 다시 걺
route = "/b06-section";
const setsBefore = guardSets;
out.samePage = await guard();
await tick();
out.reArmed = guardSets === setsBefore + 1;
out.askedSamePage = asked.length;
// 다른 페이지 — 취소 = 머묾
route = "/b08-quantity";
choice = "cancel";
out.cancelLeave = await guard();
// 저장 실패(깨끗한 표 없음) = 머묾 · 저장 성공 = 넘어감 · 뒤로 안 물음
choice = "save";
saveMarks = false;
out.saveFailLeave = await guard();
saveMarks = true;
out.saveLeave = await guard();
out.saves = saves;
out.afterSaveDirty = leave.designDirty(P);
out.afterSaveUnload = unloadBlocked();
const askedAfterSave = asked.length;
out.afterSaveLeave = await guard();
out.askedAfterSave = asked.length - askedAfterSave;
// 저장 뒤 다시 고침 — 다시 물음 · 버리고 넘어감 = 고침 칸만 지움
session.setItem(`aislo:draft:alignment-draft:${P}:7`, '{"station_offsets":{"20.000":0.3}}');
session.setItem(`aislo:draft:route-draft:${P}`, '{"rev":2}');
out.reEditDirty = leave.designDirty(P);
choice = "discard";
out.discardLeave = await guard();
out.left = {
crossdesign: session.getItem(`aislo:draft:crossdesign:${P}:7`),
alignment: session.getItem(`aislo:draft:alignment-draft:${P}:7`),
routeDraft: session.getItem(`aislo:draft:route-draft:${P}`),
selection: session.getItem(`aislo:draft:selection:${P}`),
stdCross: session.getItem(`aislo:draft:std-cross:${P}`),
b08: session.getItem(`aislo:draft:b08-materials:${P}`),
};
out.afterDiscardDirty = leave.designDirty(P);
// 페이지가 화면에서 빠짐 — 안 물음
session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"40.000":{}}');
root.isConnected = false;
const askedGone = asked.length;
out.goneLeave = await guard();
out.goneUnload = unloadBlocked();
out.askedGone = asked.length - askedGone;
process.stdout.write(JSON.stringify(out));
})();
@@ -0,0 +1,57 @@
"""346-4 떠남 확인 — B05 · B06 저장 안 한 조작이 있으면 탭 닫기 · 다른 페이지 때 물음.
./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_design_leave.py -q
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).resolve().parent / "helper_346_4_design_leave.cjs"
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], 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["emptyDirty"] is False
assert out["nonEditDirty"] is False and out["nonEditUnload"] is False
assert out["nonEditLeave"] is True and out["askedNonEdit"] == 0
def test_횡단_초안이면_물음(out) -> None:
assert out["editDirty"] is True and out["editUnload"] is True
assert out["cancelLeave"] is False
def test_B05_B06_사이는_안_묻고_다시_걺(out) -> None:
assert out["samePage"] is True and out["reArmed"] is True and out["askedSamePage"] == 0
def test_저장_실패는_머묾_성공_뒤엔_안_물음(out) -> None:
assert out["saveFailLeave"] is False
assert out["saveLeave"] is True and out["saves"] == 2
assert out["afterSaveDirty"] is False and out["afterSaveUnload"] is False
assert out["afterSaveLeave"] is True and out["askedAfterSave"] == 0
def test_버리고_넘어감은_고침_칸만_지움(out) -> None:
assert out["reEditDirty"] is True and out["discardLeave"] is True
left = out["left"]
assert left["crossdesign"] is None and left["alignment"] is None and left["routeDraft"] is None
assert left["selection"] and left["stdCross"] and left["b08"]
assert out["afterDiscardDirty"] is False
def test_페이지가_빠지면_안_물음(out) -> None:
assert out["goneLeave"] is True and out["goneUnload"] is False and out["askedGone"] == 0
@@ -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"]
+1
View File
@@ -113,4 +113,5 @@ export const ui_locales_b4 = {
"B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.",
"Unsaved edits in B05 · B06 — save there first, then link.",
],
B05_Design_Leave: ["B05 · B06 에 저장 안 한 조작이 있음", "Unsaved edits in B05 · B06"],
} as const;