feat(B05 되돌리기): 346-4 한 줄 — 노선 편집 되돌리기를 페이지 사진첩 하나로 합침
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -45,6 +45,7 @@ const STUBS = {
|
||||
canUndo: () => false,
|
||||
canRedo: () => false,
|
||||
}),
|
||||
setPageHistory: () => undefined,
|
||||
},
|
||||
"./B05_Profile_UI_Profile_Tools": {
|
||||
createProfileTools: () => ({ mode: () => "none", clear: () => undefined }),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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_편집기는_본문_페이지_수명():
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user