feat(CAD): 337-1 가-1 옵션 틀 — 물음 꼴 · 친 글 사다리 한 곳 · 명령 끝 → 쉼 · 순서 엔진 v2 · 키보드 흠

- 새 src/commands/prompt.ts — PromptSpec · renderPrompt(키워드 물음은 「또는」 없음) · matchOption(initget 줄임) · 세션 기억 · OPT · SELECT_OBJECTS
- 새 inputController/prompt-input.ts — routeTyped 사다리(점 · 옵션 묻는 중 명령 이름 거절 · 식 · 단위 거절 · 투명 ') · 알림 줄 · 덧물음 · cancelActiveCommand · undoCommand
- 새 tools/factories/end-command.ts — final(END) 갈고리 → 같은 틱 선택 도구 · state.ts 갈고리 · LASTPOINT
- sequence-tool v2 — ROUTE · id · prompt · when · next · options · keyword · loopFrom · escCommits · pickfirst · 확정 뒤 기본 끝 · Esc 늘 끝
- 흠 — N1 ②③ · N5 · N6 · N11 · N18(앞글자 실행 없앰) · H1(Ctrl · Alt 글자 안 쌓음 · Ctrl+C · X · Shift+C · Shift+V · 1) · 우클릭 = 쉴 때 반복 · SE 뺌 · ED 더함
- 시험 — cad-drive 도우미 · prompt-frame · prompt-contract · 옛 시험 손질(keyboard-chain · ime-sink · command-catalog · input-prefs-cad · eraser 녹화)

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 23:55:58 +09:00
co-authored by Claude Opus 5.5
parent 33207a450c
commit 83e246deb4
24 changed files with 2055 additions and 531 deletions
@@ -25,4 +25,10 @@ describe("command-catalog.json", () => {
expect(new Set(aliases).size).toBe(aliases.length);
expect(aliases.filter((a) => ids.has(a))).toEqual([]);
});
it("337 acad.pgp 맞춤 — SE 없음(acad = DSETTINGS) · ED = TEXTEDIT", () => {
const owner = (alias: string) => catalog.find((c) => c.aliases.includes(alias))?.id;
expect(owner("SE")).toBeUndefined();
expect(owner("ED")).toBe("TEXTEDIT");
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* CAD 시험 공용 조작(337 설계 6-2) — 빈 도면 · 쉬는 상태(선택 도구)에서 키 · 마우스를 사람처럼 넣음.
* 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정.
* 시험 캔버스는 축척 1 · y 위 + · 세계 (x, y) = 화면 (x, 높이 − y).
*/
import { vi } from "vitest";
import type { InputController } from "../../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
import {
currentNotice,
currentPrompt,
} from "../../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
import {
getActiveToolActor,
getEntities,
setSelectedEntityIds,
} from "../../../../B07_DesignDetail/openwebcad/src/state";
import { ActorEvent } from "../../../../B07_DesignDetail/openwebcad/src/tools/tool.types";
import { initApplication } from "../../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
import { CANVAS_HEIGHT } from "../../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
export type Mods = Partial<Pick<KeyboardEvent, "shiftKey" | "ctrlKey" | "altKey" | "metaKey">> & {
code?: string;
repeat?: boolean;
};
let controller: InputController;
/** 빈 도면 · 고른 것 없음 · 선택 도구(쉼)로 시작 — 앱 도우미가 켠 LINE 첫 물음에서 Esc */
export function initIdle(): InputController {
controller = initApplication();
getActiveToolActor()?.send({ type: ActorEvent.ESC });
setSelectedEntityIds([]);
return controller;
}
export const driver = (): InputController => controller;
/** 키 하나 — `key` 는 KeyboardEvent.key 꼴(「a」 · 「Enter」 · 「Escape」 · 「 」) */
export function key(k: string, mods: Mods = {}) {
const preventDefault = vi.fn();
controller.handleKeyStroke({
key: k,
code: mods.code ?? "",
keyCode: 0,
isComposing: false,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
repeat: false,
target: null,
preventDefault,
stopPropagation: vi.fn(),
...mods,
} as unknown as KeyboardEvent);
return preventDefault;
}
/** 글자만 침(제출 안 함) */
export const typed = (text: string) => {
for (const ch of text) key(ch);
};
/** 글자 + Space(= Enter) */
export const type = (text: string) => {
typed(text);
key(" ");
};
/** 글자 + Enter */
export const typeEnter = (text: string) => {
typed(text);
key("Enter");
};
const mouse = (x: number, y: number, button: number, mods: Mods = {}) =>
({
button,
clientX: x,
clientY: CANVAS_HEIGHT - y,
target: null,
shiftKey: false,
ctrlKey: false,
preventDefault: () => {},
stopPropagation: () => {},
...mods,
}) as unknown as MouseEvent;
/** 세계 좌표 (x, y) 로 커서 이동 */
export const move = (x: number, y: number) =>
controller.handleMouseMove({ clientX: x, clientY: CANVAS_HEIGHT - y } as MouseEvent);
export const press = (x: number, y: number, mods: Mods = {}) => {
move(x, y);
controller.handleMouseDown(mouse(x, y, 0, mods));
};
export const release = (x: number, y: number, mods: Mods = {}) => {
move(x, y);
controller.handleMouseUp(mouse(x, y, 0, mods));
};
/** 왼쪽 누름 한 번(누름 + 뗌 같은 자리) */
export const click = (x: number, y: number, mods: Mods = {}) => {
press(x, y, mods);
release(x, y, mods);
};
/** a 에서 눌러 b 에서 뗌 */
export const drag = (a: [number, number], b: [number, number], mods: Mods = {}) => {
press(a[0], a[1], mods);
move(b[0], b[1]);
release(b[0], b[1], mods);
};
export const rightClick = (shift = false) =>
controller.handleMouseUp(mouse(0, 0, 2, { shiftKey: shift }));
/** 가운데 단추 두 번 */
export const middleDouble = (x: number, y: number) => {
for (let i = 0; i < 2; i++) {
controller.handleMouseDown(mouse(x, y, 1));
controller.handleMouseUp(mouse(x, y, 1));
}
};
/** 도면 객체(유형 이름 거름 — 「Line」 · 「Circle」 …) */
export const entities = (type?: string) =>
getEntities().filter((entity) => !type || entity.getType() === type);
/** 커서 곁 첫 줄(지금 물음) */
export const promptLine = () => currentPrompt().line;
/** 커서 곁 둘째 줄(거절 · 안내) */
export const noticeLine = () => currentNotice();
/** 맨 위 도구 이름(Tool) */
export const activeTool = () => getActiveToolActor()?.getSnapshot()?.context?.type;
export const toolState = () => getActiveToolActor()?.getSnapshot()?.value;
/** 친 글(커서 곁 입력 칸) */
export const inputText = () => (controller as unknown as { text: string }).text;
export const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -76,6 +76,7 @@ describe("내 별칭", () => {
it("CAD 키 입력 — 「qq␣」 = 사각형 명령", () => {
savePrefs({ cadAliases: { RECTANG: ["QQ"] } });
const controller = initApplication();
getActiveToolActor()?.send({ type: "ESC" }); // 쉬는 상태에서(337 — 점 묻는 중 명령 이름 거절)
for (const k of ["q", "q", " "]) {
controller.handleKeyStroke({
key: k,
+13 -7
View File
@@ -1,6 +1,7 @@
/**
* CAD 키보드 연속 명령(PLAN 331 · 요구 224-7) — 오토캐드 방식.
* CAD 키보드 연속 명령(PLAN 331 · 요구 224-7 · 337 손질) — 오토캐드 방식.
* 각도 고정 `<30` → 길이 · 시퀀스 첫 점 숫자 · 이동 · 복사 · 회전 키보드 값 · 선 중 C · U · 한 번 스냅 · 동적 입력 Tab.
* 337 — 쉬는 상태에서 시작(cad-drive) · 점 묻는 중 명령 이름 거절 · Esc 한 번 = 명령 끝.
* 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad keyboard-chain
*/
@@ -22,7 +23,7 @@ import {
setSelectedEntityIds,
} from "../../../B07_DesignDetail/openwebcad/src/state";
import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
import { initIdle, noticeLine } from "./helpers/cad-drive";
import { CANVAS_HEIGHT } from "../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
type Xy = { x: number; y: number };
@@ -80,7 +81,7 @@ let controller: InputController;
beforeEach(() => {
vi.mocked(toast.error).mockClear();
controller = initApplication();
controller = initIdle();
setEntities([], true);
setSelectedEntityIds([]);
});
@@ -144,6 +145,7 @@ describe("331-1 각도 고정 → 길이", () => {
expect(snapshot?.status).toBe("active");
expect(snapshot?.value).toBe("STEP_0");
expect(toast.error).not.toHaveBeenCalled();
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 · 상대 좌표 = 거절
type(controller, "0,0");
type(controller, "<0");
type(controller, "100");
@@ -240,17 +242,20 @@ describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => {
expectXy(drawn[1].end, 20, 0);
});
it("선 중 다른 명령 이름은 그대로 명령", () => {
it("선 중 다른 명령 이름은 거절 · 선 그대로(337 · 오토캐드)", () => {
drawOpen();
type(controller, "circle");
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("CIRCLE");
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("LINE");
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
expect(lines()).toHaveLength(2);
});
it("END 한 번 — 둘레 안 끝점으로 · 한 점 뒤 풂", () => {
type(controller, "l");
type(controller, "0,0");
type(controller, "100,0");
key(controller, "Escape");
key(controller, "Escape"); // 명령 끝
type(controller, "l");
type(controller, "end");
expect(getSnapOverride()).toBe("END");
clickAt(controller, 80, 20); // 끝점 (100, 0) 에서 28 — 일반 스냅(15)은 못 닿음
@@ -329,7 +334,8 @@ describe("331-4 동적 입력 길이 Tab 각도", () => {
typed("12");
tab();
expect((controller as unknown as { text: string }).text).toBe("12");
key(controller, "Escape"); // 친 글만 지움
key(controller, "Backspace");
key(controller, "Backspace"); // 친 글만 지움(Esc = 명령 끝)
type(controller, "0,0");
typed("@5,0");
tab();
@@ -0,0 +1,65 @@
/**
* 337 계약 시험(설계 2-16) — 모든 도구 머신의 평평한 상태가 물음 약속을 지키는지.
* ① `prompt.options` key 마다 `OPTION.<key>` 또는 `OPTION.*` 처리 ② 한 물음 안 kw 안 겹침(없는 옵션 포함)
* ③ `meta.kind` 있는 상태마다 ESC ④ `prompt` 와 옛 `instructions` 를 같이 두지 않음 ⑤ 함수 꼴 물음은 빈 context 로 그려짐.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad prompt-contract
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
}));
import {
type PromptMeta,
resolvePrompt,
} from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
import { TOOL_STATE_MACHINES } from "../../../B07_DesignDetail/openwebcad/src/commands/registry";
type StateNode = { config: { meta?: PromptMeta; on?: Record<string, unknown> } };
const rows = Object.entries(TOOL_STATE_MACHINES).flatMap(([tool, machine]) =>
Object.entries((machine as { states: Record<string, StateNode> }).states).map(([name, node]) => ({
where: `${tool}.${name}`,
meta: node.config.meta,
on: node.config.on ?? {},
})),
);
describe("물음 약속(2-16)", () => {
it("도구 머신을 읽음", () => {
expect(rows.length).toBeGreaterThan(100);
});
it("옵션 처리 · kw 안 겹침 · 함수 꼴은 빈 context 로", () => {
const problems: string[] = [];
for (const { where, meta, on } of rows) {
if (!meta?.prompt) continue;
let spec: ReturnType<typeof resolvePrompt>;
try {
spec = resolvePrompt(meta.prompt, {});
} catch (error) {
problems.push(`${where}: 빈 context 로 못 그림 (${(error as Error).message})`);
continue;
}
for (const option of spec?.options ?? []) {
if (!on[`OPTION.${option.key}`] && !on["OPTION.*"]) {
problems.push(`${where}: OPTION.${option.key} 처리 없음`);
}
}
const kws = [...(spec?.options ?? []), ...(spec?.absent ?? [])].map((option) => option.kw);
const twice = kws.filter((kw, index) => kws.indexOf(kw) !== index);
if (twice.length) problems.push(`${where}: kw 겹침 ${twice.join(",")}`);
}
expect(problems).toEqual([]);
});
it("meta.kind 있는 상태 = ESC 있음 · prompt 와 instructions 같이 없음", () => {
const problems: string[] = [];
for (const { where, meta, on } of rows) {
if (meta?.kind && !on.ESC) problems.push(`${where}: ESC 없음`);
if (meta?.prompt && meta.instructions) problems.push(`${where}: prompt + instructions`);
}
expect(problems).toEqual([]);
});
});
+558
View File
@@ -0,0 +1,558 @@
/**
* 337 가-1 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl.
* 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 2 장 · 6-3 가 줄.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad prompt-frame
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
}));
import { Actor } from "xstate";
import {
OPT,
type OptionSpec,
type PromptSpec,
SELECT_OBJECTS,
matchOption,
previousSelection,
recall,
remember,
renderPrompt,
} from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
import {
getLastCommandId,
runCommandInput,
} from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
import {
pushSubPrompt,
routeTyped,
} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
import {
getActiveToolActor,
getSelectedEntityIds,
setActiveToolActor,
setSelectedEntityIds,
} from "../../../B07_DesignDetail/openwebcad/src/state";
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
import { createSequenceTool } from "../../../B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool";
import {
activeTool,
click,
driver,
entities,
initIdle,
inputText,
key,
noticeLine,
promptLine,
rightClick,
toolState,
type,
typeEnter,
typed,
} from "./helpers/cad-drive";
type Xy = { x: number; y: number };
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
const start = (machine: unknown) => setActiveToolActor(new Actor(machine as never), false);
const enter = () => key("Enter");
const lineIds = () => entities("Line").map((entity) => entity.id);
beforeEach(() => {
initIdle();
});
describe("옵션 맞추기(2-4)", () => {
const prompt: PromptSpec = {
text: "다음 점 지정",
options: [
opt("CENTER", "C", "중심"),
opt("CLOSE", "CL", "닫기"),
{ key: "LTYPE", kw: "LT", en: "LTYPE", label: "선종류" },
OPT.UNDO,
OPT.ALL,
],
absent: [{ key: "ELEVATION", kw: "E", en: "ELEVATION", label: "고도" }],
};
const keyOf = (text: string) => {
const hit = matchOption(text, prompt);
return hit && "option" in hit
? hit.option.key
: hit && "absent" in hit
? `없음:${hit.absent.key}`
: null;
};
it("대소문자 · 앞 `_` · 빈칸 · kw · en · 사이 길이", () => {
expect(keyOf("c")).toBe("CENTER");
expect(keyOf(" _center ")).toBe("CENTER");
expect(keyOf("CE")).toBe("CENTER");
expect(keyOf("cl")).toBe("CLOSE"); // kw 딱 맞음 우선
expect(keyOf("clo")).toBe("CLOSE");
expect(keyOf("u")).toBe("UNDO");
expect(keyOf("und")).toBe("UNDO");
expect(keyOf("lty")).toBe("LTYPE");
});
it("kw 보다 짧으면 안 맞음 · 숨김도 맞음 · 없는 옵션 안내 · 엉뚱한 글", () => {
expect(keyOf("l")).toBeNull(); // LT 의 L 은 모자람(initget)
expect(keyOf("all")).toBe("ALL");
expect(keyOf("e")).toBe("없음:ELEVATION");
expect(keyOf("zz")).toBeNull();
});
});
describe("물음 그림(2-3)", () => {
it("옵션 · 기본 · 숨김", () => {
expect(renderPrompt({ text: "다음 점 지정", options: [OPT.CLOSE, OPT.UNDO] })).toBe(
"다음 점 지정 또는 [닫기(C)/명령 취소(U)]:",
);
expect(
renderPrompt({
text: "기준점 지정",
options: [OPT.DISPLACEMENT],
default: { kind: "option", key: "DISPLACEMENT" },
}),
).toBe("기준점 지정 또는 [변위(D)] <변위>:");
expect(renderPrompt(SELECT_OBJECTS)).toBe("객체 선택:");
expect(
renderPrompt({
text: "두 번째 점 지정",
default: { kind: "enter", label: "첫 번째 점을 변위로 사용" },
}),
).toBe("두 번째 점 지정 <첫 번째 점을 변위로 사용>:");
});
it("값 기본 — 소수 넷 · 정수 · 각 · 좌표 · 기억이 처음 값을 이김 · null 은 안 그림", () => {
const length: PromptSpec = {
text: "직사각형의 길이 지정",
default: { kind: "value", remember: "T.RECT", initial: 10 },
};
expect(renderPrompt(length)).toBe("직사각형의 길이 지정 <10.0000>:");
remember("T.RECT", 12.5);
expect(renderPrompt(length)).toBe("직사각형의 길이 지정 <12.5000>:");
expect(recall("T.RECT")).toBe(12.5);
expect(
renderPrompt({
text: "변의 수 입력",
default: { kind: "value", remember: "T.INT", initial: 4, format: "int" },
}),
).toBe("변의 수 입력 <4>:");
expect(
renderPrompt({
text: "각 지정",
default: { kind: "value", remember: "T.ANG", initial: 22.5, format: "ang" },
}),
).toBe("각 지정 <22.5>:");
expect(
renderPrompt({
text: "변위 지정",
default: { kind: "value", remember: "T.XY", initial: { x: 0, y: 0 } },
}),
).toBe("변위 지정 <0.0000, 0.0000>:");
expect(
renderPrompt({
text: "반지름 지정",
default: { kind: "value", remember: "T.NULL", initial: null },
}),
).toBe("반지름 지정:");
});
});
/** 빈 Enter 넷 갈래 · 숫자 검사 · 키워드 — 변의 수 → 중심 → 내접/외접 → 점 되풀이 */
const commits: unknown[][] = [];
const polygonLike = createSequenceTool({
tool: Tool.POLYGON,
repeatLastStep: true,
steps: [
{
kind: "number",
id: "sides",
prompt: {
text: "변의 수 입력",
default: { kind: "value", remember: "T.SIDES", initial: 4, format: "int" },
number: { int: true, min: 3, max: 1024 },
},
},
{
kind: "point",
id: "center",
prompt: {
text: "중심 지정",
default: { kind: "value", remember: "T.CENTER", initial: { x: 5, y: 6 } },
},
},
{
kind: "keyword",
id: "ic",
prompt: {
text: "옵션 입력",
options: [opt("INSCRIBED", "I", "원에 내접"), opt("CIRCUMSCRIBED", "C", "원에 외접")],
default: { kind: "option", key: "INSCRIBED", label: "I" },
},
},
{
kind: "point",
id: "pts",
prompt: { text: "다음 점 지정", default: { kind: "enter", label: "끝" } },
},
],
commit: (input) => {
const center = input.get<Xy>("center");
commits.push([input.get("sides"), center.x, center.y, input.get("ic"), input.points().length]);
},
});
describe("빈 Enter 넷 갈래 · 거절 · 기억 · 끝(2-5 · 2-6 · 2-8 · 2-9)", () => {
beforeEach(() => {
commits.length = 0;
});
it("값(숫자) · 값(좌표) · 옵션 · enter → 확정 → 같은 틱에 쉬는 상태", () => {
start(polygonLike);
expect(promptLine()).toBe("변의 수 입력 <4>:");
enter(); // 4
expect(promptLine()).toBe("중심 지정 <5.0000, 6.0000>:");
enter(); // (5, 6)
expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] <I>:");
enter(); // INSCRIBED
type("1,1");
expect(promptLine()).toBe("다음 점 지정 <끝>:");
enter(); // 되풀이 점 = 확정
expect(commits).toEqual([[4, 5, 6, "INSCRIBED", 2]]);
expect(activeTool()).toBe(Tool.SELECT); // await 없이
expect(toolState()).toBe("WAITING_FOR_FIRST_SELECT_POINT");
});
it("숫자 물음 — 식 · 단위 · 정수 · 범위 · 명령 이름 거절 · 받은 값은 다음 <기본>", () => {
start(polygonLike);
const rejects: Array<[string, string]> = [
["3.2*1.5", "숫자 값을 요구함."],
["2m", "숫자 값을 요구함."],
["l", "숫자 값을 요구함."],
["2.5", "정수 값을 요구함."],
["2", "3에서 1024 사이의 정수를 요구함."],
];
for (const [text, message] of rejects) {
type(text);
expect(noticeLine()).toBe(message);
expect(toolState()).toBe("STEP_0");
expect(inputText()).toBe("");
}
type("6");
type("0,0");
type("c"); // 소문자 키워드
type("1,0");
enter();
expect(commits).toEqual([[6, 0, 0, "CIRCUMSCRIBED", 2]]);
start(polygonLike);
expect(promptLine()).toBe("변의 수 입력 <6>:"); // 세션 기억
enter();
expect(promptLine()).toBe("중심 지정 <0.0000, 0.0000>:"); // 누른 점도 기억
});
it("점 물음 — 명령 이름 · 엉뚱한 글 거절(도구 그대로) · 키워드 물음 거절 글", () => {
start(polygonLike);
enter();
type("circle");
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
expect(activeTool()).toBe(Tool.POLYGON);
type("0,0");
type("x");
expect(noticeLine()).toBe("옵션 키워드가 잘못됨.");
expect(toolState()).toBe("STEP_2");
});
it("Esc 한 번 = 명령 끝(확정 없음) · 기록 줄", () => {
start(polygonLike);
enter();
type("0,0");
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
expect(commits).toEqual([]);
});
});
describe("엔진 v2 — loopFrom · when · mode · next · escCommits · undo · close · pickfirst(2-13)", () => {
it("loopFrom = 앞 값 둠 · mode · when · next · 되풀이 첫 물음 Enter = 끝", () => {
const seen: unknown[] = [];
const tool = createSequenceTool({
tool: Tool.DIST,
loopFrom: "at",
steps: [
{
kind: "number",
id: "d",
prompt: { text: "거리 지정", default: { kind: "value", remember: "T.D", initial: 1 } },
},
{
kind: "point",
id: "at",
prompt: { text: "점 지정", options: [opt("MODE", "M", "모드")] },
options: { MODE: { mode: "m", next: "extra" } },
},
{
kind: "number",
id: "extra",
when: (input) => input.mode === "m" && !input.has("extra"),
next: "at",
prompt: { text: "덧 값" },
},
],
commit: (input) =>
seen.push([
input.get("d"),
input.get<Xy>("at").x,
input.has("extra") ? input.get("extra") : null,
input.mode,
]),
});
start(tool);
enter(); // d = 1
type("1,1");
expect(seen).toEqual([[1, 1, null, null]]);
expect(promptLine()).toBe("점 지정 또는 [모드(M)]:"); // 거리는 둠 · 점부터 다시
type("m");
expect(promptLine()).toBe("덧 값:");
type("7");
type("2,2");
expect(seen[1]).toEqual([1, 2, 7, "m"]);
enter(); // 한 번 돈 뒤 첫 되풀이 물음 Enter = 끝
expect(activeTool()).toBe(Tool.SELECT);
});
const polys: Array<{ points: number; closed: boolean }> = [];
const plineLike = createSequenceTool({
tool: Tool.DIST,
repeatLastStep: true,
escCommits: true,
steps: [
{ kind: "point", prompt: { text: "시작점 지정" } },
{
kind: "point",
prompt: (input) => ({
text: "다음 점 지정",
options: input.points().length >= 3 ? [OPT.CLOSE, OPT.UNDO] : [OPT.UNDO],
}),
options: { CLOSE: "close", UNDO: "undo" },
},
],
commit: (input) => polys.push({ points: input.points().length, closed: input.closed }),
});
it("undo · close(셋째 점부터) · escCommits · 점 하나 Esc = 버림", () => {
polys.length = 0;
start(plineLike);
type("0,0");
type("10,0");
type("c"); // 아직 닫기 없음
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
type("10,10");
expect(promptLine()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:");
type("u");
type("20,0");
key("Escape"); // 모은 값으로 확정 뒤 끝
expect(polys).toEqual([{ points: 3, closed: false }]);
expect(activeTool()).toBe(Tool.SELECT);
start(plineLike);
type("0,0");
type("10,0");
type("10,10");
type("c");
expect(polys[1]).toEqual({ points: 3, closed: true });
start(plineLike);
type("0,0");
key("Escape");
expect(polys).toHaveLength(2);
});
it("고르기 첫 단계 — 고른 것 없이 Enter = 끝 · 고른 채 시작 = 바로 다음 물음(PICKFIRST) · 이전(P) 기억", () => {
const picked: number[] = [];
const tool = createSequenceTool({
tool: Tool.DIST,
steps: [{ kind: "selection" }, { kind: "point", prompt: { text: "기준점 지정" } }],
commit: (input) => picked.push(input.entities(0).length),
});
start(tool);
expect(promptLine()).toBe("객체 선택:");
enter();
expect(activeTool()).toBe(Tool.SELECT);
expect(picked).toEqual([]);
type("l");
type("0,0");
type("10,0");
key("Escape");
const ids = lineIds();
setSelectedEntityIds(ids);
start(tool);
expect(promptLine()).toBe("기준점 지정:");
expect(previousSelection()).toEqual(ids);
type("5,5");
expect(picked).toEqual([1]);
});
it("고르기 명령 Esc = 끝 + 고름 풂", () => {
const tool = createSequenceTool({
tool: Tool.DIST,
steps: [{ kind: "selection" }, { kind: "point", prompt: { text: "기준점 지정" } }],
commit: () => {},
});
type("l");
type("0,0");
type("10,0");
key("Escape");
setSelectedEntityIds(lineIds());
start(tool);
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
expect(getSelectedEntityIds()).toEqual([]);
});
});
describe("쉼 · 명령 이름 · 별칭(2-5 ⑨ · 2-17 · N18)", () => {
it("딱 맞음만 — R · LE · CIR · SE = 알 수 없는 명령 · l = LINE · ed = TEXTEDIT", () => {
for (const text of ["r", "le", "cir", "se"]) {
type(text);
expect(noticeLine()).toBe(`알 수 없는 명령 「${text.toUpperCase()}」.`);
expect(activeTool()).toBe(Tool.SELECT);
}
type("ed");
expect(activeTool()).toBe(Tool.TEXTEDIT);
key("Escape");
type("l");
expect(activeTool()).toBe(Tool.LINE);
});
it("빈 Enter · Space · 우클릭(쉴 때) = 앞 명령 반복", () => {
type("l");
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
rightClick();
expect(activeTool()).toBe(Tool.LINE);
key("Escape");
key(" ");
expect(activeTool()).toBe(Tool.LINE);
});
it("Esc 한 번 = 선 끝(그린 토막 남음) · 쉴 때 Esc = 고름 풂", () => {
type("l");
type("0,0");
type("10,0");
typed("12");
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
expect(inputText()).toBe("");
expect(entities("Line")).toHaveLength(1);
setSelectedEntityIds(lineIds());
key("Escape");
expect(getSelectedEntityIds()).toEqual([]);
});
it("명령행 소문자 옵션(N6) — 선 c = 닫기", () => {
type("l");
type("0,0");
type("10,0");
type("10,10");
driver().submitText("c");
expect(entities("Line")).toHaveLength(3);
});
});
describe("명령 바꿈 · 되돌림 · 덧물음 · 투명(2-8 · 2-11 · N1 · N11)", () => {
const drawSelected = () => {
type("l");
type("0,0");
type("20,0");
key("Escape");
setSelectedEntityIds(lineIds());
};
it("N1 — 이동 기준점 뒤 딴 명령 = 끊고(원본 되살림) 켬 · 선 1 개", () => {
drawSelected();
type("move");
type("0,0");
runCommandInput("CIRCLE");
expect(activeTool()).toBe(Tool.CIRCLE);
expect(entities("Line")).toHaveLength(1);
});
it("N1 — 이동 도중 Ctrl+Z = 끊고 뒤 되돌림 · 같은 id 두 벌 없음", () => {
drawSelected();
type("move");
type("0,0");
key("z", { ctrlKey: true, code: "KeyZ" });
const ids = entities().map((entity) => entity.id);
expect(new Set(ids).size).toBe(ids.length);
expect(activeTool()).toBe(Tool.SELECT);
});
it("쉴 때 U = UNDO · 명령 중 U 는 옵션일 때만", () => {
type("l");
type("0,0");
type("10,0");
key("Escape");
expect(entities("Line")).toHaveLength(1);
type("u");
expect(entities("Line")).toHaveLength(0);
start(polygonLike);
type("u");
expect(noticeLine()).toBe("숫자 값을 요구함.");
});
it("덧물음 — 친 점 · 누른 점이 덧물음 먼저 · {point} = 원래 물음에", () => {
const seen: Xy[] = [];
type("l");
pushSubPrompt({
prompt: { text: "덧 점 지정" },
kind: "point",
onPoint: (point) => {
seen.push({ x: point.x, y: point.y });
return seen.length === 1 ? "stay" : { point };
},
});
expect(promptLine()).toBe("덧 점 지정:");
type("3,4");
expect(promptLine()).toBe("덧 점 지정:"); // stay
click(30, 40);
expect(seen).toEqual([
{ x: 3, y: 4 },
{ x: 30, y: 40 },
]);
expect(toolState()).toBe("WAITING_FOR_END_POINT"); // (30, 40) 이 선 첫 점으로
type("@10,0");
const [line] = entities("Line") as unknown as Array<{ segment: { start: Xy; end: Xy } }>;
expect(line.segment.start.x).toBeCloseTo(30, 3);
expect(line.segment.end.x).toBeCloseTo(40, 3);
});
it("투명 `'` — 도구형은 거절 · 즉시형은 도구 둔 채", () => {
type("l");
type("'line");
expect(noticeLine()).toBe("「LINE」 명령은 투명하게 쓸 수 없음.");
expect(activeTool()).toBe(Tool.LINE);
routeTyped("'zoomin");
routeTyped("'zoomout");
expect(getLastCommandId()).toBe("ZOOMOUT");
expect(activeTool()).toBe(Tool.LINE);
});
});
describe("Ctrl · Alt 글자(H1)", () => {
it("Ctrl+C = COPYCLIP · Ctrl+글자 · Alt+글자는 쌓지 않음", () => {
key("c", { ctrlKey: true, code: "KeyC" });
expect(getLastCommandId()).toBe("COPYCLIP");
expect(activeTool()).toBe(Tool.SELECT); // 원(C) 아님
key("ㅌ", { ctrlKey: true, code: "KeyX" });
expect(getLastCommandId()).toBe("CUTCLIP");
key("l", { altKey: true });
key("q", { ctrlKey: true, code: "KeyQ" });
expect(inputText()).toBe("");
typeEnter("l");
expect(activeTool()).toBe(Tool.LINE);
});
});