feat(설정): 332-2 ☰ [설정] 창 — 마우스 · CAD 단축키 · 다른 곳 단축키 탭 · [저장] 때만 계정 pref · 닫을 때 물음

- app_shell ☰ 설정 묶음에 [설정] 단추(서랍 닫고 창은 처음 누를 때 불러옴)
- 새 app_settings.ts(틀 · 탭 셋 · [저장] · askLeave) · app_settings_cad.ts(명령 표 · 내 별칭 · 검사) · app_settings.css
- 새 openwebcad/src/commands/command-catalog.json(명령 153 · 한글 이름 · 기본 별칭) + 거울 시험
- locale 문구(ui_template_locale_common)
- 시험: test_app_settings · cad/command-catalog 새로 · test_device_audit · test_touch_bridge 화면 주소 환경값(AISLO_UI_BASE · AISLO_UI_PROJECT)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-15 12:45:03 +09:00
co-authored by Claude Opus 5.5
parent b1e8d6cb7f
commit aaecfa3438
11 changed files with 824 additions and 5 deletions
@@ -0,0 +1,28 @@
/**
* CAD 명령 목록 거울(PLAN 332-2) — `commands/command-catalog.json` 이 registry(`commands.*.ts`)와 같은지.
* ☰ [설정] CAD 단축키 탭은 xstate 가 딸려 오지 않게 JSON 만 읽음 — 명령을 더하거나 별칭을 바꾸면 JSON 도 같이.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad command-catalog
*/
import { describe, expect, it } from "vitest";
import catalog from "../../../B07_DesignDetail/openwebcad/src/commands/command-catalog.json";
import { getAllCommands } from "../../../B07_DesignDetail/openwebcad/src/commands/registry";
describe("command-catalog.json", () => {
it("registry 와 같은 명령 · 한글 이름 · 기본 별칭 · 차례", () => {
const live = getAllCommands().map((c) => ({
id: c.id,
label: c.label,
aliases: c.aliases ?? [],
}));
expect(catalog).toEqual(live);
});
it("기본 별칭 = 영문 · 숫자 · 안 겹침 · 명령 이름과 다름", () => {
const ids = new Set(catalog.map((c) => c.id));
const aliases = catalog.flatMap((c) => c.aliases);
expect(aliases.every((a) => /^[A-Z0-9]+$/.test(a))).toBe(true);
expect(new Set(aliases).size).toBe(aliases.length);
expect(aliases.filter((a) => ids.has(a))).toEqual([]);
});
});
+36
View File
@@ -0,0 +1,36 @@
/* PLAN 332-2 단위검증 헬퍼 — ☰ [설정] CAD 별칭 검사(`A00_Common/app_settings_cad.ts`)를 Node 로 돌림.
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_app_settings.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 file = path.join(ROOT, "A00_Common", "app_settings_cad.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
const req = (p) =>
p.endsWith(".json")
? require(path.join(ROOT, "A00_Common", p))
: p === "@ui/ui_template_locale"
? { t: (k) => k }
: {};
new Function("exports", "module", "require", js)(box.exports, box, req);
const m = box.exports;
const check = (aliases) => m.checkCadAliases(aliases);
process.stdout.write(
JSON.stringify({
commands: m.CAD_COMMANDS.length,
parse: [m.parseAliases(" ll, Cc x1 "), m.parseAliases(""), m.parseAliases(",, ")],
ok: check({ LINE: ["LL"], CIRCLE: ["CC", "C2"], COPY: ["C"] }),
chars: check({ LINE: ["L!"], CIRCLE: ["원"] }),
dup: check({ LINE: ["LL"], CIRCLE: ["LL"], ARC: ["AA", "AA"] }),
name: check({ CIRCLE: ["LINE"] }),
}),
);
+187
View File
@@ -0,0 +1,187 @@
"""PLAN 332-2 — ☰ [설정] 창(`A00_Common/app_settings.ts` · `app_settings_cad.ts` · `app_shell.ts`).
① CAD 별칭 검사(Node) — 영문 · 숫자만 · 겹침 금지 · 명령 이름 금지 · 칸 글 나눔
② 실제 브라우저(sub2 화면 서버 5177 · 로그인 없음 = 이 브라우저만 · 새 창이라 남는 것 없음)
☰ → [설정] = 서랍 닫히고 창 · 탭 셋 전환 · 고쳐도 [저장] 전엔 안 씀 · 닫을 때 물음(취소 = 머묾 ·
버림 = 안 씀 · 저장 = 한 번) · [저장] 한 번 = 한 번 씀 · 저장 뒤 읽기 층 방향 뒤집힘 · 잘못 별칭은 안 씀
"""
from __future__ import annotations
import json
import os
import subprocess
import urllib.request
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
BASE = "http://localhost:5177" # sub2 화면 서버(OWNERS 자기 줄)
KEY = "aislo:pref:input-prefs"
# 앱 스크립트보다 먼저 — 설정 키에 쓰는 횟수를 셈
COUNT_WRITES = f"""
window.__writes = [];
const set = Storage.prototype.setItem;
Storage.prototype.setItem = function (k, v) {{
if (k === {json.dumps(KEY)}) window.__writes.push(v);
return set.call(this, k, v);
}};
"""
# 앱이 불러온 그 읽기 층 사본(vite 가 ?t= 를 붙였을 수 있음)
WHEEL_IN = (
"async (d) => (await import(performance.getEntriesByType('resource')"
".map((e) => e.name).find((n) => n.includes('/input_prefs.ts')))).wheelIn(d)"
)
@pytest.fixture(scope="module")
def node() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_app_settings.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_별칭_검사(node):
assert node["commands"] == 153
assert node["parse"] == [["LL", "CC", "X1"], [], []]
assert node["ok"] == {} # 남의 기본 별칭(C = CIRCLE)을 내 것으로 덮는 건 됨(acad.pgp 식)
assert node["chars"] == {"LINE": "chars", "CIRCLE": "chars"}
assert node["dup"] == {"LINE": "dup", "CIRCLE": "dup", "ARC": "dup"}
assert node["name"] == {"CIRCLE": "name"}
def _up() -> bool:
try:
urllib.request.urlopen(BASE, timeout=3)
return True
except Exception:
return False
def _open_settings(page):
page.click(".app-burger")
page.click(".app-drawer__settings")
page.wait_for_selector(".app-settings")
assert not page.evaluate(
"document.querySelector('.app-header, header')?.classList.contains('is-drawer-open')"
)
def _writes(page) -> list:
return page.evaluate("window.__writes")
def _visible_panel_has(page, selector: str) -> bool:
return page.evaluate(
"(s) => [...document.querySelectorAll('.app-settings .ui-modal__body > div:not([role]), "
".app-settings .ui-modal__body > table')].some((p) => !p.hidden && p.querySelector(s))",
selector,
)
def _save(page):
page.click(".app-settings .ui-modal__actions button")
def _close(page):
page.click(".app-settings .ui-modal__top > button")
@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐")
def test_설정_창_탭_저장_물음():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
ctx = browser.new_context(
viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True
)
ctx.add_init_script(COUNT_WRITES)
page = ctx.new_page()
page.goto(f"{BASE}/#/a01-home")
page.wait_for_selector(".app-burger")
assert page.evaluate(WHEEL_IN, 100) is True # 기본 = 당김
# ☰ → [설정] · 탭 셋 · 첫 탭 = 마우스
_open_settings(page)
tabs = page.evaluate(
"[...document.querySelectorAll('.app-settings [role=tab]')]"
".map((b) => [b.dataset.settingsTab, b.getAttribute('aria-selected')])"
)
assert tabs == [["mouse", "true"], ["cad", "false"], ["keys", "false"]]
assert _visible_panel_has(page, "input[type=radio]")
page.click("[data-settings-tab=cad]")
assert _visible_panel_has(page, "input[data-cad-command]")
assert not _visible_panel_has(page, "input[type=radio]")
assert page.locator(".app-settings input[data-cad-command]").count() == 153
page.click("[data-settings-tab=keys]")
assert (
page.evaluate(
"document.querySelector('[data-settings-tab=keys]').getAttribute('aria-selected')"
)
== "true"
)
page.click("[data-settings-tab=mouse]")
# 밀기 고름 — [저장] 전엔 안 씀 · 닫으면 물음 · 취소 = 머묾
page.check("input[name=app-settings-wheel][value=push]")
assert _writes(page) == []
page.keyboard.press("Escape")
page.wait_for_selector(".ui-confirm")
page.click(".ui-confirm button >> nth=0") # 취소
assert page.locator(".app-settings").count() == 1 and _writes(page) == []
# [저장] = 한 번 씀 · 읽기 층 방향 뒤집힘 · 닫을 때 안 물음
_save(page)
assert len(_writes(page)) == 1
assert json.loads(_writes(page)[0]) == {"wheelZoom": "push", "cadAliases": {}}
assert page.evaluate(WHEEL_IN, 100) is False and page.evaluate(WHEEL_IN, -100) is True
_save(page) # 고친 것 없음 — 또 안 씀
assert len(_writes(page)) == 1
_close(page)
assert page.locator(".app-settings").count() == 0
assert page.locator(".ui-confirm").count() == 0
# CAD 별칭 — 잘못이면 안 씀 · 칸 표시
_open_settings(page)
assert page.is_checked("input[name=app-settings-wheel][value=push]") # 저장값으로 엶
page.click("[data-settings-tab=cad]")
line = "input[data-cad-command=LINE]"
circle = "input[data-cad-command=CIRCLE]"
for line_v, circle_v in (("L!", ""), ("LL", "ll"), ("", "LINE")):
page.fill(line, line_v)
page.fill(circle, circle_v)
_save(page)
assert page.inner_text(".app-settings__error").strip(), (line_v, circle_v)
assert page.locator(".app-settings input[aria-invalid=true]").count() >= 1
assert len(_writes(page)) == 1
page.fill(line, "ll")
page.fill(circle, "")
_save(page)
assert page.inner_text(".app-settings__error").strip() == ""
assert len(_writes(page)) == 2
assert json.loads(_writes(page)[1]) == {"wheelZoom": "push", "cadAliases": {"LINE": ["LL"]}}
# 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더
page.click("[data-settings-tab=mouse]")
page.check("input[name=app-settings-wheel][value=pull]")
_close(page)
page.click(".ui-confirm button >> nth=1") # 버리고 넘어감
assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2
assert page.evaluate(WHEEL_IN, 100) is False
_open_settings(page)
page.check("input[name=app-settings-wheel][value=pull]")
_close(page)
page.click(".ui-confirm button >> nth=2") # 저장 후 넘어감
assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3
assert json.loads(_writes(page)[2])["wheelZoom"] == "pull"
assert page.evaluate(WHEEL_IN, 100) is True
browser.close()
+4 -2
View File
@@ -12,14 +12,16 @@ sub1 서버(5174)에서 모든 페이지를 네 크기로 열어 문제 수를
"""
import json
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
BASE = "http://localhost:5174"
PROJECT_NAME = "검증_sub1"
# 다른 창 서버로 돌릴 때 — AISLO_UI_BASE · AISLO_UI_PROJECT(OWNERS 자기 줄)
BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5174")
PROJECT_NAME = os.environ.get("AISLO_UI_PROJECT", "검증_sub1")
PAGES = [
"a01-home",
"a02-prog-detail",
+2 -1
View File
@@ -8,11 +8,12 @@ sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자
"""
import json
import os
import urllib.request
import pytest
BASE = "http://localhost:5174"
BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5174") # 다른 창 서버 = 환경값
def _up() -> bool: