Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 — 588 단축키 등록부 · UI_Edit 충돌(sub1 고르기 · 속성 판 + sub4 keyIs) 양쪽 살림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 19:24:37 +09:00
co-authored by Claude Opus 5.5
34 changed files with 1304 additions and 193 deletions
+69 -1
View File
@@ -2,6 +2,8 @@
* CAD 설정 배선(PLAN 332-3) — ☰ [설정] 휠 확대 방향 · 내 별칭(`A00_Common/input_prefs.ts`).
* 휠 = 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 337) · 당김 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대.
* 별칭 = 명령 이름 > 내 별칭 > 기본 별칭 · 저장 즉시 따름.
* 바꾼 키(588-3) = 새 키만 먹음 · 옛 키는 안 먹음 · 한글 자판도 자판 자리로.
* 옮기기 버튼(588-4) = 오른쪽을 고르면 오른쪽 끌기로 화면 옮김 · 기본(가운데)이면 오른쪽 끌기는 안 옮김.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad input-prefs-cad
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -11,7 +13,12 @@ import {
matchCommandPrefixes,
resolveCommandInput,
} from "../../../B07_DesignDetail/openwebcad/src/commands/registry";
import { getActiveToolActor } from "../../../B07_DesignDetail/openwebcad/src/state";
import {
getActiveToolActor,
getPanStartLocation,
getScreenCanvasDrawController,
getSnapEnabled,
} from "../../../B07_DesignDetail/openwebcad/src/state";
import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
const store = new Map<string, string>();
@@ -96,3 +103,64 @@ describe("내 별칭", () => {
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("RECTANGLE");
});
});
describe("바꾼 키(588-3)", () => {
const key = (controller: ReturnType<typeof initApplication>, extra: Partial<KeyboardEvent>) =>
controller.handleKeyStroke({
key: "",
code: "",
keyCode: 0,
isComposing: false,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
repeat: false,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...extra,
} as unknown as KeyboardEvent);
it("스냅 F3 → Ctrl+Q — 새 키(한글 자판 「ㅂ」)만 켜고 끔 · F3 안 먹음", () => {
const controller = initApplication();
const before = getSnapEnabled();
key(controller, { key: "F3", code: "F3" });
expect(getSnapEnabled()).toBe(!before);
savePrefs({ keys: { "cad.snap": ["Ctrl+Q"] } });
key(controller, { key: "F3", code: "F3" });
expect(getSnapEnabled()).toBe(!before);
key(controller, { key: "ㅂ", code: "KeyQ", ctrlKey: true });
expect(getSnapEnabled()).toBe(before);
});
});
describe("옮기기 버튼(588-4)", () => {
const mouse = (button: number, x: number, y = 100) =>
({
button,
clientX: x,
clientY: y,
shiftKey: false,
ctrlKey: false,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
}) as unknown as MouseEvent;
const rightDrag = (controller: ReturnType<typeof initApplication>) => {
const before = getScreenCanvasDrawController().getScreenOffset().clone();
controller.handleMouseDown(mouse(2, 100));
controller.handleMouseMove(mouse(2, 160));
controller.handleMouseUp(mouse(2, 160));
const after = getScreenCanvasDrawController().getScreenOffset();
return Math.hypot(after.x - before.x, after.y - before.y);
};
it("기본(가운데) = 오른쪽 끌기 안 옮김 · 오른쪽 고름 = 옮김 · 놓으면 끝", () => {
const controller = initApplication();
expect(rightDrag(controller)).toBe(0);
savePrefs({ panButton: "right" });
expect(rightDrag(controller)).toBeGreaterThan(0);
expect(getPanStartLocation()).toBeNull();
});
});
@@ -82,6 +82,7 @@ globalThis.ResizeObserver = class {
const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts"));
const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), {
"../common_util/common_util_view_window": viewWindow,
"../A00_Common/input_prefs": load(path.join(ROOT, "A00_Common", "input_prefs.ts")),
});
const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), {
"../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod,
@@ -36,6 +36,11 @@ global.Element = Element;
const stubs = {
"../A00_Common/touch_bridge": { wheelZoomsIn: () => true },
"../A00_Common/input_prefs": {
isPanButton: (b) => b === 1,
PAN_MOVE_PX: 4,
swallowNextMenu() {},
},
"../B04_PreProcess/B04_PreProcess_UI_MapRender": {
computeMaxScale: () => 10,
lonLatToScreen: () => ({ x: 0, y: 0 }),
@@ -0,0 +1,109 @@
/* PLAN 588 지킴 헬퍼 — 단축키 등록부(`A00_Common/key_registry.ts`) · CAD 묶음 · 찾기(`app_settings_find.ts`)
* 를 그 자리에서 트랜스파일해 Node 로 돌림. 가짜 localStorage 로 바꾼 키를 넣어 봄.
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_588_key_registry.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const mods = {};
function load(rel) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const req = (p) => mods[path.basename(p)] ?? {};
new Function("exports", "module", "require", js)(box.exports, box, req);
return (mods[path.basename(rel, ".ts")] = box.exports);
}
load("A00_Common/input_prefs.ts");
load("A00_Common/key_shortcut.ts");
load("B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts");
const reg = load("A00_Common/key_registry.ts");
const find = load("A00_Common/app_settings_find.ts");
const catalog = require(
path.join(ROOT, "B07_DesignDetail/openwebcad/src/commands/command-catalog.json"),
);
const store = new Map();
globalThis.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null) };
const setKeys = (keys) => store.set(mods.input_prefs.INPUT_PREFS_KEY, JSON.stringify({ keys }));
const ev = (key, code, extra = {}) => ({
key,
code,
ctrlKey: false,
altKey: false,
shiftKey: false,
...extra,
});
const out = {};
// ① CAD 묶음 · 찾기
const groups = find.cadCommandGroups(catalog.map((c) => c.id));
out.groups = groups.map((g) => ({ label: g.label, n: g.ids.length }));
out.groupIds = groups.flatMap((g) => g.ids);
out.other = groups.find((g) => !g.label)?.ids ?? [];
const hits = (q) =>
catalog.filter((c) => find.matchesFind(q, [c.id, c.label, ...c.aliases])).map((c) => c.id);
out.find = {
sun: hits("선"),
L: hits("L"),
sunPl: hits("선 pl"),
blank: hits(" ").length,
none: hits("zzqq"),
};
// ② 키 글
out.chord = {
hangulCtrlZ: reg.chordOf(ev("ㅋ", "KeyZ", { ctrlKey: true })),
ctrlShiftZ: reg.chordOf(ev("Z", "KeyZ", { ctrlKey: true, shiftKey: true })),
meta: reg.chordOf(ev("z", "KeyZ", { metaKey: true })),
space: reg.chordOf(ev(" ", "Space")),
shiftOnly: reg.chordOf(ev("Shift", "ShiftLeft", { shiftKey: true })),
process: reg.chordOf(ev("Process", "")),
f3: reg.chordOf(ev("F3", "F3")),
shiftDigit: reg.chordOf(ev("!", "Digit1", { ctrlKey: true, shiftKey: true })),
arrow: reg.chordOf(ev("ArrowUp", "ArrowUp", { shiftKey: true })),
label: reg.keyLabel("Shift+ArrowUp"),
};
// ③ 판정 — 기본 · 바꾼 키 · 못 바꾸는 줄은 바꾼 값 무시
const del = ev("Delete", "Delete");
out.keyIs = {
delDefault: reg.keyIs(del, "plan.delete"),
hangulUndo: reg.keyIs(ev("ㅋ", "KeyZ", { ctrlKey: true }), "plan.undo"),
};
setKeys({ "plan.delete": ["Ctrl+D"], "all.escape": ["Q"] });
out.keyIs.delAfter = reg.keyIs(del, "plan.delete");
out.keyIs.ctrlDAfter = reg.keyIs(ev("ㅇ", "KeyD", { ctrlKey: true }), "plan.delete");
out.keyIs.fixedIgnored = reg.actionKeys("all.escape");
out.keyIs.cadAction = reg.keyAction(ev("F7", "F7"), "cad");
store.clear();
// ④ 겹침 검사
const chk = (keys) => reg.checkKeys(keys);
out.check = {
none: chk({}),
sameScope: chk({ "plan.delete": ["Ctrl+Z"] }),
withAll: chk({ "plan.delete": ["Escape"] }),
withLoupe: chk({ "plan.delete": ["Z"] }),
otherPage: chk({ "plan.delete": ["F3"] }),
reserved: chk({ "plan.delete": ["F5"] }),
cadLetter: chk({ "cad.snap": ["Q"] }),
cadShiftLetter: chk({ "cad.snap": ["Shift+Q"] }),
cadCtrl: chk({ "cad.snap": ["Ctrl+Q"] }),
samePage: chk({ "b05.undo": ["Enter"] }),
swap: chk({ "plan.undo": ["Ctrl+Y"], "plan.redo": ["Ctrl+Z"] }),
both: chk({ "plan.drawFinish": ["F2"], "plan.drawBack": ["F2"] }),
};
// ⑤ 등록부 꼴
out.actions = reg.KEY_ACTIONS.map((a) => ({
id: a.id,
scope: a.scope,
keys: a.keys,
fixed: !!a.fixed,
}));
out.scopes = Object.keys(reg.KEY_SCOPES);
process.stdout.write(JSON.stringify(out));
+2 -2
View File
@@ -137,7 +137,7 @@ def test_되돌리기_배선_자유지정_먼저():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : history.undo()' in panel
assert 'tools.mode() === "free" ? free.canUndo() : history.canUndo()' in panel
keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :]
keys = panel[panel.index('if (redo || keyIs(event, "b05.undo")) {') :] # 588-3 등록부
free_at = keys.index('if (tools.mode() === "free") {')
assert (
free_at < keys.index("history.undo()")
@@ -159,7 +159,7 @@ def test_도구줄_직선화_오른쪽_자유지정():
def test_적용은_직선화와_같은_길_키_배선():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert "apply: (next) => ctx.applyEdits(next)" in panel
assert 'tools.mode() === "free" && free.key(key)' in panel
assert 'tools.mode() === "free" && freeKey && free.key(freeKey)' in panel
profile = _read("A00_Common/design/design_profile.ts")
# applyEdits = 캐시(store.replace) + 되돌리기 기록 — 자동저장 없음
assert "store.replace(next);\n history.record();" in profile
+1 -1
View File
@@ -47,7 +47,7 @@ def test_Ctrl_Z_는_사진첩_한_줄(out):
def test_종단도_Ctrl_Z_줄이_사진첩_하나():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {')
at = tools.index('if (redo || keyIs(event, "b05.undo")) {') # 588-3 등록부
assert (
"undoOwner" not in tools and "history.undo()" in tools[at : at + 600]
) # 367-3 자유지정 분기 앞에 섬
+1 -1
View File
@@ -57,7 +57,7 @@ def test_다시하기는_앞으로(out) -> None:
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
assert 'keyIs(event, "b05.redo")' in tools and "setPageHistory(history)" in tools # 588-3
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()
+152
View File
@@ -0,0 +1,152 @@
"""PLAN 588 — 단축키 등록부 · 겹침 판정 · CAD 리본 묶음 · 찾기 (요구 445 C2 · C3).
· 등록부 = `A00_Common/key_registry.ts` — ☰ [설정] 표가 여기서 그려짐 · 바꿀 수 있는 줄은 코드가 부름
· 키 글 = 한글 자판에서도 자판 자리로(Ctrl+ㅋ = Ctrl+Z) · 바꾼 키만 먹음 · 못 바꾸는 줄은 바꾼 값 무시
· 겹침 = 같은 화면 자리끼리 · 「모든 화면」과 · 브라우저 몫 · CAD 글자 단독 — 다른 화면끼리는 같은 키 됨
· CAD 묶음 = 리본 탭 › 패널 · 154 명령이 꼭 한 묶음 · 기타 여섯(리본에 없음)
짝: `helper_588_key_registry.cjs`(실제 TS 를 Node 로 돌림).
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
def _read(rel: str) -> str:
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
return f.read()
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_588_key_registry.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_CAD_154_명령_꼭_한_묶음_기타_여섯(r):
ids = r["groupIds"]
assert len(ids) == 154 and len(set(ids)) == 154
assert sorted(r["other"]) == ["LAYER", "PLACETABLE", "RECOVER", "REDO", "TOAREA", "UNDO"]
assert r["groups"][0]["label"] == "홈 › 그리기"
assert r["groups"][-1]["label"] == "" # 기타 이름은 화면 글(locale)
def test_찾기_낱말_모두_대소문자_무시(r):
f = r["find"]
assert {"LINE", "PLINE"} <= set(f["sun"]) and {"LINE", "PLINE"} <= set(f["L"])
assert "PLINE" in f["sunPl"] and "LINE" not in f["sunPl"] # 낱말 모두
assert f["blank"] == 154 and f["none"] == []
def test_키_글_한글_자판_안전(r):
c = r["chord"]
assert c["hangulCtrlZ"] == "Ctrl+Z"
assert c["ctrlShiftZ"] == "Ctrl+Shift+Z"
assert c["meta"] == "Ctrl+Z"
assert c["space"] == "Space"
assert c["shiftOnly"] == "" and c["process"] == ""
assert c["f3"] == "F3"
assert c["shiftDigit"] == "Ctrl+Shift+1"
assert c["arrow"] == "Shift+ArrowUp" and c["label"] == "Shift+↑"
def test_바꾼_키만_먹음_못_바꾸는_줄은_무시(r):
k = r["keyIs"]
assert k["delDefault"] is True and k["hangulUndo"] is True
assert k["delAfter"] is False and k["ctrlDAfter"] is True
assert k["fixedIgnored"] == ["Escape"]
assert k["cadAction"] == "cad.grid"
def test_겹침_판정(r):
c = r["check"]
assert c["none"] == {}
assert c["sameScope"] == {"plan.delete": "dup"}
assert c["withAll"] == {"plan.delete": "dup"}
assert c["withLoupe"] == {"plan.delete": "dup"}
assert c["otherPage"] == {} # D03 과 CAD 는 다른 화면
assert c["reserved"] == {"plan.delete": "reserved"}
assert c["cadLetter"] == {"cad.snap": "letter"}
assert c["cadShiftLetter"] == {"cad.snap": "letter"}
assert c["cadCtrl"] == {}
assert c["samePage"] == {"b05.undo": "dup"} # B05 · B06 한 페이지 — 횡단 Enter 와 겹침
assert c["swap"] == {} # 서로 바꿈은 됨
assert c["both"] == {"plan.drawFinish": "dup", "plan.drawBack": "dup"}
def test_등록부_꼴(r):
ids = [a["id"] for a in r["actions"]]
assert len(ids) == len(set(ids))
for a in r["actions"]:
assert a["scope"] in r["scopes"] and a["keys"]
assert a["id"].startswith(a["scope"] + ".")
# 바꿀 수 있는 줄 = 코드가 실제로 부름(등록부에만 있고 안 먹는 키 없음)
WIRED = {
"b05": "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
"profile": "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
"plan": "D03_PlanAnalysis",
"cad": "B07_DesignDetail/openwebcad/src/inputController",
}
def _sources(rel: str) -> str:
full = os.path.join(ROOT, rel)
if os.path.isfile(full):
return _read(rel)
names = sorted(n for n in os.listdir(full) if n.endswith(".ts"))
return "\n".join(_read(os.path.join(rel, n)) for n in names)
def test_바꿀_수_있는_줄은_배선됨(r):
for a in r["actions"]:
if a["fixed"]:
continue
src = _sources(WIRED[a["scope"]])
assert f'"{a["id"]}"' in src or f"'{a['id']}'" in src, a["id"]
def test_손글_안내_지움_표는_등록부():
assert "App_Settings_Keys_" not in _read("ui_template/ui_template_locale_common.ts")
assert "App_Settings_Keys_" not in _read("A00_Common/app_settings.ts")
# 588-4 — 화면 옮기기 자리는 모두 설정 버튼을 따름(가운데 고정 판정이 남지 않음)
PAN_SITES = [
"B05_Profile/B05_Profile_UI_RouteEdit_Input.ts", # B05 · B06 겹침 평면 · D03 지도
"B07_DesignDetail/openwebcad/src/inputController/input-controller.ts",
"A00_Common/b_svg_zoom_pan.ts",
"B04_PreProcess/B04_PreProcess_UI_MapViewer.ts",
"B04_PreProcess/B04_PreProcess_UI_Camera.ts",
"B05_Profile/B05_Profile_UI_Drainage_Interact.ts",
"B05_Profile/B05_Profile_UI_Profile_Wheel.ts",
"B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts",
"M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts",
]
@pytest.mark.parametrize("rel", PAN_SITES)
def test_옮기기_자리는_설정_버튼(rel):
src = _read(rel)
assert "isPanButton(" in src or "isAuxPanButton(" in src, rel