Files
Aislo/resources/tester/cad/keyboard-chain.test.ts
T

344 lines
11 KiB
TypeScript

/**
* 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
*/
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 { initIdle, noticeLine } from "./helpers/cad-drive";
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 = initIdle();
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 첫 점에 숫자 → 거절 · 도구 살아 있음 · 알림 0(첫 점 @x,y = 마지막 점 기준 — prompt-frame)", () => {
type(controller, "pline");
type(controller, "100");
const snapshot = getActiveToolActor()?.getSnapshot();
expect(snapshot?.status).toBe("active");
expect(snapshot?.value).toBe("START"); // 337 나-1 폴리선 손 머신
expect(toast.error).not.toHaveBeenCalled();
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 = 거절
type(controller, "0,0");
type(controller, "<0");
type(controller, "100");
expect(getActiveToolActor()?.getSnapshot()?.context.segments).toHaveLength(1);
});
});
/** 선 (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()?.context.type).toBe("SELECT"); // 337 — 닫으면 명령 끝
});
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("선 중 다른 명령 이름은 거절 · 선 그대로(337 · 오토캐드)", () => {
drawOpen();
type(controller, "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"); // 명령 끝
type(controller, "l");
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, "Backspace");
key(controller, "Backspace"); // 친 글만 지움(Esc = 명령 끝)
type(controller, "0,0");
typed("@5,0");
tab();
enter();
expectXy(lines()[0].end, 5, 0);
});
});