- 기준점 있는 점 단계에서만 Tab 가로챔(그 밖은 브라우저 초점 이동) · 숫자 넣고 Tab = 그 칸 잠금 + 다음 칸 · 빈 Tab = 칸만 바꿈 - 각도 칸 잠금 = 각도 고정 · 길이 잠근 채 Enter = 그 길이 · `100` Tab `30` Enter → (86.603, 50) - 커서 옆 줄 `▸[길이 100 🔒] [각도 30_]` — 잠금 🔒 · 지금 칸 ▸ · 나머지는 기준점→커서 실측 - 한 점 · Esc · 새 명령이면 각도 고정 · 길이 잠금 모두 풂(releaseOnePoint) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
/**
|
|
* CAD 키보드 연속 명령(PLAN 331 · 요구 224-7) — 오토캐드 방식.
|
|
* 각도 고정 `<30` → 길이 · 시퀀스 첫 점 숫자 · 이동 · 복사 · 회전 키보드 값 · 선 중 C · U · 한 번 스냅 · 동적 입력 Tab.
|
|
* 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정.
|
|
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad keyboard-chain
|
|
*/
|
|
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 { toast } from "react-toastify";
|
|
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
|
|
import { getAngleLock } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-angle-lock";
|
|
import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override";
|
|
import {
|
|
getActiveToolActor,
|
|
getEntities,
|
|
getLastStateInstructions,
|
|
setEntities,
|
|
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 { CANVAS_HEIGHT } from "../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
|
|
|
|
type Xy = { x: number; y: number };
|
|
type Seg = { start: Xy; end: Xy };
|
|
|
|
const key = (controller: InputController, k: string) =>
|
|
controller.handleKeyStroke({
|
|
key: k,
|
|
code: "",
|
|
keyCode: 0,
|
|
isComposing: false,
|
|
shiftKey: false,
|
|
ctrlKey: false,
|
|
altKey: false,
|
|
metaKey: false,
|
|
repeat: false,
|
|
target: null,
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
} as unknown as KeyboardEvent);
|
|
|
|
/** 글자를 치고 Space(= Enter) */
|
|
const type = (controller: InputController, text: string) => {
|
|
for (const ch of text) key(controller, ch);
|
|
key(controller, " ");
|
|
};
|
|
|
|
/** 세계 좌표 (x, y) 로 커서 이동 — 시험 캔버스는 축척 1 · y 위 + */
|
|
const move = (controller: InputController, x: number, y: number) =>
|
|
controller.handleMouseMove({ clientX: x, clientY: CANVAS_HEIGHT - y } as MouseEvent);
|
|
|
|
const clickAt = (controller: InputController, x: number, y: number) => {
|
|
move(controller, x, y);
|
|
controller.handleMouseUp({
|
|
button: 0,
|
|
clientX: x,
|
|
clientY: CANVAS_HEIGHT - y,
|
|
target: null,
|
|
preventDefault: () => {},
|
|
stopPropagation: () => {},
|
|
} as unknown as MouseEvent);
|
|
};
|
|
|
|
const lines = () =>
|
|
getEntities()
|
|
.filter((entity) => entity.getType() === "Line")
|
|
.map((entity) => (entity as unknown as { segment: Seg }).segment);
|
|
|
|
const expectXy = (point: Xy, x: number, y: number) => {
|
|
expect(point.x).toBeCloseTo(x, 3);
|
|
expect(point.y).toBeCloseTo(y, 3);
|
|
};
|
|
|
|
let controller: InputController;
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(toast.error).mockClear();
|
|
controller = initApplication();
|
|
setEntities([], true);
|
|
setSelectedEntityIds([]);
|
|
});
|
|
|
|
describe("331-1 각도 고정 → 길이", () => {
|
|
it("L · 0,0 · <30 · 100 → (86.603, 50) · <90 · 50 → (86.603, 100)", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
move(controller, 500, 500); // 커서 방향과 무관하게 고정 각도로
|
|
type(controller, "<30");
|
|
expect(getAngleLock()).toBe(30);
|
|
type(controller, "100");
|
|
expect(getAngleLock()).toBeNull(); // 한 점만
|
|
type(controller, "<90");
|
|
type(controller, "50");
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(2);
|
|
expectXy(drawn[0].start, 0, 0);
|
|
expectXy(drawn[0].end, 86.603, 50);
|
|
expectXy(drawn[1].end, 86.603, 100);
|
|
});
|
|
|
|
it("고정 없이 숫자 = 마우스 방향", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
move(controller, 0, 300);
|
|
type(controller, "100");
|
|
expectXy(lines()[0].end, 0, 100);
|
|
});
|
|
|
|
it("고정 중 클릭 = 고정 방향 위로 내림 · Esc · 새 명령이면 풂", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
type(controller, "<0");
|
|
clickAt(controller, 60, 25); // 가이드에서 먼 자리(스냅 안 붙게)
|
|
expectXy(lines()[0].end, 60, 0);
|
|
type(controller, "<45");
|
|
key(controller, "Escape");
|
|
expect(getAngleLock()).toBeNull();
|
|
type(controller, "<45");
|
|
runCommandInput("CIRCLE");
|
|
expect(getAngleLock()).toBeNull();
|
|
});
|
|
|
|
it("음수 · .5 숫자 · 음수 각도", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
type(controller, "<-90");
|
|
type(controller, ".5");
|
|
expectXy(lines()[0].end, 0, -0.5);
|
|
type(controller, "<0");
|
|
type(controller, "-10");
|
|
expectXy(lines()[1].end, -10, -0.5);
|
|
});
|
|
|
|
it("PLINE 첫 점에 숫자 · @x,y → 무시 · 도구 살아 있음 · 알림 0", () => {
|
|
type(controller, "pline");
|
|
type(controller, "100");
|
|
type(controller, "@10,0");
|
|
const snapshot = getActiveToolActor()?.getSnapshot();
|
|
expect(snapshot?.status).toBe("active");
|
|
expect(snapshot?.value).toBe("STEP_0");
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
type(controller, "0,0");
|
|
type(controller, "<0");
|
|
type(controller, "100");
|
|
expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
/** 선 (x1,y1)-(x2,y2) 하나를 키보드로 그리고 고름 */
|
|
const drawAndSelect = (x1: number, y1: number, x2: number, y2: number) => {
|
|
type(controller, "l");
|
|
type(controller, `${x1},${y1}`);
|
|
type(controller, `${x2},${y2}`);
|
|
key(controller, "Escape");
|
|
key(controller, "Escape");
|
|
setSelectedEntityIds(getEntities().map((entity) => entity.id));
|
|
};
|
|
|
|
describe("331-2 이동 · 복사 · 회전 키보드 값", () => {
|
|
it("MOVE · 0,0 · @10,0 → 오른쪽 10", () => {
|
|
drawAndSelect(0, 0, 20, 0);
|
|
type(controller, "move");
|
|
type(controller, "0,0");
|
|
type(controller, "@10,0");
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(1);
|
|
expectXy(drawn[0].start, 10, 0);
|
|
expectXy(drawn[0].end, 30, 0);
|
|
});
|
|
|
|
it("COPY · 0,0 · <0 · 5 → 원본 + 오른쪽 5", () => {
|
|
drawAndSelect(0, 0, 20, 0);
|
|
type(controller, "copy");
|
|
type(controller, "0,0");
|
|
type(controller, "<0");
|
|
type(controller, "5");
|
|
const starts = lines()
|
|
.map((segment) => segment.start.x)
|
|
.sort((a, b) => a - b);
|
|
expect(starts).toHaveLength(2);
|
|
expect(starts[0]).toBeCloseTo(0, 3);
|
|
expect(starts[1]).toBeCloseTo(5, 3);
|
|
});
|
|
|
|
it("ROTATE · 0,0 · 90 → 반시계 90°", () => {
|
|
drawAndSelect(10, 0, 20, 0);
|
|
type(controller, "rotate");
|
|
type(controller, "0,0");
|
|
type(controller, "90");
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(1);
|
|
expectXy(drawn[0].start, 0, 10);
|
|
expectXy(drawn[0].end, 0, 20);
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => {
|
|
const drawOpen = () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
type(controller, "10,0");
|
|
type(controller, "10,10");
|
|
};
|
|
|
|
it("C = 첫 점으로 닫고 끝 · 안내 「[닫기(C)/명령취소(U)]」", () => {
|
|
drawOpen();
|
|
expect(getLastStateInstructions()).toBe("다음 점 지정 또는 [닫기(C)/명령취소(U)]:");
|
|
type(controller, "c");
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(3);
|
|
expectXy(drawn[2].start, 10, 10);
|
|
expectXy(drawn[2].end, 0, 0);
|
|
expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_START_POINT");
|
|
expect(getLastStateInstructions()).toBe("첫 점 지정 (x,y):");
|
|
});
|
|
|
|
it("C 는 둘째 선부터 — 한 구간이면 무시", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
type(controller, "10,0");
|
|
type(controller, "c");
|
|
expect(lines()).toHaveLength(1);
|
|
expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_END_POINT");
|
|
});
|
|
|
|
it("U = 마지막 한 구간만 취소 · 그 시작점에서 이어 그림", () => {
|
|
drawOpen();
|
|
type(controller, "u");
|
|
expect(lines()).toHaveLength(1);
|
|
type(controller, "20,0");
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(2);
|
|
expectXy(drawn[1].start, 10, 0);
|
|
expectXy(drawn[1].end, 20, 0);
|
|
});
|
|
|
|
it("선 중 다른 명령 이름은 그대로 명령", () => {
|
|
drawOpen();
|
|
type(controller, "circle");
|
|
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("CIRCLE");
|
|
});
|
|
|
|
it("END 한 번 — 둘레 안 끝점으로 · 한 점 뒤 풂", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
type(controller, "100,0");
|
|
key(controller, "Escape");
|
|
type(controller, "end");
|
|
expect(getSnapOverride()).toBe("END");
|
|
clickAt(controller, 80, 20); // 끝점 (100, 0) 에서 28 — 일반 스냅(15)은 못 닿음
|
|
expect(getSnapOverride()).toBeNull();
|
|
type(controller, "@0,10");
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(2);
|
|
expectXy(drawn[1].start, 100, 0);
|
|
expectXy(drawn[1].end, 100, 10);
|
|
});
|
|
|
|
it("END 둘레에 끝점 없으면 클릭 버림 · Esc 로 풂", () => {
|
|
type(controller, "l");
|
|
type(controller, "end");
|
|
clickAt(controller, 500, 500);
|
|
expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_START_POINT");
|
|
expect(getSnapOverride()).toBe("END");
|
|
key(controller, "Escape");
|
|
expect(getSnapOverride()).toBeNull();
|
|
});
|
|
|
|
it("점 단계가 아니면 END 는 스냅 아님", () => {
|
|
key(controller, "Escape");
|
|
key(controller, "Escape");
|
|
type(controller, "end");
|
|
expect(getSnapOverride()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("331-4 동적 입력 길이 Tab 각도", () => {
|
|
const tab = () => key(controller, "Tab");
|
|
const enter = () => key(controller, "Enter");
|
|
const typed = (text: string) => {
|
|
for (const ch of text) key(controller, ch);
|
|
};
|
|
|
|
it("100 Tab 30 ⏎ → (86.603, 50)", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
move(controller, 0, 300);
|
|
typed("100");
|
|
tab();
|
|
typed("30");
|
|
enter();
|
|
const drawn = lines();
|
|
expect(drawn).toHaveLength(1);
|
|
expectXy(drawn[0].end, 86.603, 50);
|
|
expect(getAngleLock()).toBeNull();
|
|
});
|
|
|
|
it("빈 Tab → 각도 칸 · 30 Tab(잠금) · 100 ⏎ → (86.603, 50)", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
move(controller, 0, 300);
|
|
tab();
|
|
typed("30");
|
|
tab();
|
|
expect(getAngleLock()).toBe(30);
|
|
typed("100");
|
|
enter();
|
|
expectXy(lines()[0].end, 86.603, 50);
|
|
});
|
|
|
|
it("길이만 잠그고 빈 ⏎ = 커서 방향 그 길이", () => {
|
|
type(controller, "l");
|
|
type(controller, "0,0");
|
|
move(controller, 0, 300);
|
|
typed("40");
|
|
tab();
|
|
enter();
|
|
expectXy(lines()[0].end, 0, 40);
|
|
});
|
|
|
|
it("기준점 없는 단계 · 숫자 아닌 글이면 Tab 은 손대지 않음", () => {
|
|
type(controller, "l");
|
|
typed("12");
|
|
tab();
|
|
expect((controller as unknown as { text: string }).text).toBe("12");
|
|
key(controller, "Escape"); // 친 글만 지움
|
|
type(controller, "0,0");
|
|
typed("@5,0");
|
|
tab();
|
|
enter();
|
|
expectXy(lines()[0].end, 5, 0);
|
|
});
|
|
});
|