Files
Aislo/resources/tester/cad/mouse-acad.test.ts
T
eomsangdonandClaude Opus 5.5 66279024d8 feat(B07 CAD): 337-2 마-2 ZOOM 덧물음 · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 종료(X) · ↶↷ 끊고 되돌림 · snapped 캐스트 지움
- ZOOM(Z): 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」 덧물음 · E · A = 범위 · P = 앞 화면 · W · 누름 = 두 구석 윈도우 · 2X = 배율 곱 · 빈 Enter = 실시간 안내 · 중심 · 동적 · 축척 · 객체 = 없음 안내 · 'Z 투명 · ⛶ 단추 = 바로 범위
- 약속 더함: SubPrompt.onText(꼴이 정해지지 않은 글 · nX) — routeTyped 가 덧물음에 먼저 물음 · undefined = 사다리 그대로(브레인 알림)
- Shift+우클릭: 점 물음에서만 한 번 스냅 메뉴(시작점에서 · 두 점 사이의 중간 · 끝점 · 중간점 · 교차점 · 중심 · 사분점 · 수직 · 노드 · 근처점 · 없음) → 고른 이름을 친 것과 같은 길 · Esc · 바깥 누름 = 메뉴만 닫음(ic/snap-menu.ts)
- 고르기 숨은 키워드 ALL · L · P(선택 도구 첫 점 대기 · 쉼 · 명령 안 자식) — 앞 고름에 더함 · 「n개를 찾음, 총 m개」
- 그립 물음 새 꼴 「** 신축 ** 신축점 지정 또는 [종료(X)]:」 · X = 놓음 · 기준점(B) · 복사(C) · 명령 취소(U) 없음 안내
- ↶ ↷ 단추 = undoCommand · redoCommand(끊고 되돌림) · 우클릭 = 명령 중 Enter · 쉴 때 반복(가-1 길 · 시험)
- 시험: mouse-acad 마-2 더함(21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 00:11:53 +09:00

453 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마).
* 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 ·
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂 ·
* 마-2: 우클릭(쉼 반복 · 명령 중 Enter) · ZOOM(E · A · W · P · 2X · 'Z) · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 X · Esc.
* 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE 도구)로만 만들고 숫자로 판정.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad mouse-acad
*/
import { afterEach, 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 { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
import { zoomPrevious } from "../../../B07_DesignDetail/openwebcad/src/inputController/zoom-prompt";
import {
isSnapMenuOpen,
pickSnapMenu,
} from "../../../B07_DesignDetail/openwebcad/src/inputController/snap-menu";
import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override";
import { selectingActor } from "../../../B07_DesignDetail/openwebcad/src/inputController/selecting";
import {
currentNotice,
currentPrompt,
} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
import { rememberSelection } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
import { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel";
import {
getActiveToolActor,
getAngleGuideEntities,
getEntities,
getHighlightedEntityIds,
getScreenCanvasDrawController,
getSelectedEntityIds,
setActiveToolActor,
setAngleStep,
setEntities,
setHostReadOnly,
setSelectedEntityIds,
setSnapEnabled,
} from "../../../B07_DesignDetail/openwebcad/src/state";
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
import { selectToolStateMachine } from "../../../B07_DesignDetail/openwebcad/src/tools/select-tool";
import { TOOL_STATE_MACHINES } from "../../../B07_DesignDetail/openwebcad/src/commands/registry";
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 };
type Mods = { shiftKey?: boolean; ctrlKey?: boolean; button?: number };
let controller: InputController;
/** 세계 (x, y) → 마우스 사건 — 시험 캔버스 화면 원점 0 · y 위 + · 배율 따름 */
const ev = (x: number, y: number, mods: Mods = {}) => {
const scale = getScreenCanvasDrawController().getScreenScale();
return {
button: 0,
shiftKey: false,
ctrlKey: false,
clientX: x * scale,
clientY: CANVAS_HEIGHT - y * scale,
target: null,
preventDefault: () => {},
stopPropagation: () => {},
...mods,
} as unknown as MouseEvent;
};
const move = (x: number, y: number, mods?: Mods) => controller.handleMouseMove(ev(x, y, mods));
const press = (x: number, y: number, mods?: Mods) => {
move(x, y, mods);
controller.handleMouseDown(ev(x, y, mods));
};
const release = (x: number, y: number, mods?: Mods) => {
move(x, y, mods);
controller.handleMouseUp(ev(x, y, mods));
};
/** 가운데 단추 누름 + 뗌(뗌 = 팬 끝) */
const middle = (x: number, y: number) => {
press(x, y, { button: 1 });
controller.handleMouseUp(ev(x, y, { button: 1 }));
};
const click = (x: number, y: number, mods?: Mods) => {
press(x, y, mods);
release(x, y, mods);
};
const key = (k: string) =>
controller.handleKeyStroke({
key: k,
code: "",
isComposing: false,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
repeat: false,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent);
const rightClick = (shift = false) =>
controller.handleMouseUp(ev(0, 0, { button: 2, shiftKey: shift }));
const typeEnter = (text: string) => {
for (const ch of text) key(ch);
key("Enter");
};
const toolType = () => getActiveToolActor()?.getSnapshot()?.context?.type;
const idle = () => setActiveToolActor(new Actor(selectToolStateMachine));
const lineTool = () => setActiveToolActor(new Actor(TOOL_STATE_MACHINES[Tool.LINE]));
const segOf = (id: string) =>
(getEntities().find((e) => e.id === id) as unknown as { segment: Seg }).segment;
const lastSeg = () => {
const lines = getEntities().filter((e) => e.getType() === "Line");
return (lines[lines.length - 1] 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);
};
/** LINE 도구로 선 하나(앱 쪽 점) — 그 id */
const drawLine = (x1: number, y1: number, x2: number, y2: number): string => {
lineTool();
click(x1, y1);
click(x2, y2);
idle();
const lines = getEntities();
return lines[lines.length - 1].id;
};
let a = "";
let b = "";
beforeEach(() => {
controller = initApplication();
setEntities([], true);
setSelectedEntityIds([]);
setAngleStep(0, false); // 극 · 직교 끔 — 가이드 스냅이 판정을 흔들지 않게
a = drawLine(0, 0, 100, 0);
b = drawLine(0, 200, 100, 200);
setAngleStep(45, false);
setSelectedEntityIds([]);
idle();
});
afterEach(() => {
setAngleStep(45, false);
setSnapEnabled(true);
setHostReadOnly(false);
vi.restoreAllMocks();
});
describe("고르기 — 누름 더함 · Shift 뺌 · 상자(H4 · H5)", () => {
it("쉴 때 누름 = 더함 · Shift+누름 = 뺌", () => {
click(50, 0);
expect(getSelectedEntityIds()).toEqual([a]);
click(50, 200);
expect(getSelectedEntityIds()).toEqual([a, b]);
click(25, 0, { shiftKey: true }); // 가운데 (50, 0) 은 그립
expect(getSelectedEntityIds()).toEqual([b]);
});
it("상자 = 앞 고름 ∪ 상자 · Shift+상자 = 앞 고름 − 상자(끌기 · 클릭-클릭 둘 다)", () => {
click(50, 0);
// 창(왼→오) 끌기로 B
press(-20, 180);
release(120, 220);
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
// Shift + 클릭-클릭 상자로 A 뺌
click(-20, -20, { shiftKey: true });
click(120, 20, { shiftKey: true });
expect(getSelectedEntityIds()).toEqual([b]);
});
it("명령 중 — 누름 = 더함 · 끌기 상자 · 스침 강조", () => {
runCommandInput("MOVE");
expect(toolType()).toBe(Tool.MOVE);
move(50, 1);
expect(getHighlightedEntityIds()).toEqual([a]);
move(50, 100);
expect(getHighlightedEntityIds()).toEqual([]);
click(50, 0);
press(-20, 180); // 빈 곳 누름 → 끌어 뗌 = 상자
release(120, 220);
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
expect(toolType()).toBe(Tool.MOVE);
});
});
describe("그립 — 누르고-옮기고-누르기 · 끌기 · 숫자 · N7 · H2", () => {
it("그립 누르고 떼면 대기(안 밀림 · H3) → 다음 누름에 놓음", () => {
click(50, 0);
click(100, 0); // 끝 그립
expect(toolType()).toBe(Tool.GRIP_EDIT);
expectXy(segOf(a).end, 100, 0);
click(130, 60);
expectXy(segOf(a).end, 130, 60);
expect(toolType()).toBe(Tool.SELECT);
});
it("뜨거운 그립 Esc · 종료(X) = 놓음(안 바뀜) · 물음 줄", () => {
click(50, 0);
click(100, 0);
expect(currentPrompt().line).toBe("** 신축 ** 신축점 지정 또는 [종료(X)]:");
move(150, 90);
key("Escape");
expect(toolType()).toBe(Tool.SELECT);
expectXy(segOf(a).end, 100, 0);
click(100, 0); // 고름은 남음 — 다시 그립
typeEnter("x");
expect(toolType()).toBe(Tool.SELECT);
expectXy(segOf(a).end, 100, 0);
});
it("그립 끌어 놓음 = 놓은 자리", () => {
click(50, 0);
press(100, 0);
release(140, 70);
expectXy(segOf(a).end, 140, 70);
expect(toolType()).toBe(Tool.SELECT);
});
it("그립 숫자 = 그립 점에서 커서 쪽 거리(N8)", () => {
click(50, 0);
click(100, 0);
move(100, 80);
typeEnter("30");
expectXy(segOf(a).end, 100, 30);
});
it("명령 안 고르기 중엔 그립을 안 집음(N7)", () => {
runCommandInput("MOVE");
click(50, 0);
click(100, 0);
expect(toolType()).toBe(Tool.MOVE);
expectXy(segOf(a).end, 100, 0);
});
it("확정 · 읽기 전용 도면 — 그립 · Delete 막음(H2)", () => {
click(50, 0);
setHostReadOnly(true);
click(100, 0);
expect(toolType()).toBe(Tool.SELECT);
key("Delete");
expect(getEntities().map((e) => e.id)).toContain(a);
setHostReadOnly(false);
key("Delete");
expect(getEntities().map((e) => e.id)).not.toContain(a);
});
});
describe("직교(H8) · 가이드 거리(H9)", () => {
const startLine = () => {
lineTool();
click(300, 300);
};
it("F8 = 직교 강제 — 누름 · 거리 숫자 · 꺼짐 비교", () => {
setAngleStep(90, false);
startLine();
click(400, 340); // 가로가 더 멂 → (400, 300)
expectXy(lastSeg().end, 400, 300);
move(420, 500); // 세로 쪽 → 거리 50 = 위로
typeEnter("50");
expectXy(lastSeg().end, 400, 350);
setAngleStep(0, false);
click(470, 380);
expectXy(lastSeg().end, 470, 380);
});
it("Shift 누른 동안 임시 직교 · 직교 켠 채 Shift = 풂", () => {
setAngleStep(0, false);
startLine();
click(400, 340, { shiftKey: true });
expectXy(lastSeg().end, 400, 300);
setAngleStep(90, false);
click(450, 370, { shiftKey: true });
expectXy(lastSeg().end, 450, 370);
});
it("객체 스냅 점은 직교가 안 덮음 · F3 꺼도 직교", () => {
setAngleStep(90, false);
lineTool();
click(10, 100);
click(98, 197); // B 시작점 근처 (100, 200) — 끝점 스냅
expectXy(lastSeg().end, 100, 200);
setSnapEnabled(false);
lineTool();
click(10, 100);
click(98, 197);
expectXy(lastSeg().end, 10, 197);
});
it("가이드 잡는 거리 = 화면 픽셀(배율 2 → 도면 7.5 · 0.5 → 30)", () => {
setAngleStep(90, false);
const view = getScreenCanvasDrawController();
view.setScreenScale(2);
startLine();
move(380, 310); // 가로 가이드에서 10
expect(getAngleGuideEntities()).toHaveLength(0);
view.setScreenScale(0.5);
move(380, 320); // 20
expect(getAngleGuideEntities()).toHaveLength(1);
view.setScreenScale(1);
});
});
describe("휠", () => {
it("기본 = 밂(앞으로 굴리면 확대 · 오토캐드) — 저장 없음", () => {
expect(wheelZoom({ deltaY: -100, deltaMode: 0, ctrlKey: false })?.delta).toBeLessThan(0);
expect(wheelZoom({ deltaY: 100, deltaMode: 0, ctrlKey: false })?.delta).toBeGreaterThan(0);
});
it("휠 두 번 = 범위 줌 · 이전 화면 되돌림 · 느리면 아님", () => {
const view = getScreenCanvasDrawController() as unknown as {
zoomToFitScreen: () => void;
setScreenScale: (s: number) => void;
getScreenScale: () => number;
};
view.zoomToFitScreen = vi.fn(() => view.setScreenScale(3));
const now = vi.spyOn(Date, "now");
now.mockReturnValue(1000);
middle(10, 10);
now.mockReturnValue(1300);
middle(11, 11);
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
expect(view.getScreenScale()).toBe(3);
expect(zoomPrevious()).toBe(true);
expect(view.getScreenScale()).toBe(1);
now.mockReturnValue(5000);
middle(10, 10);
now.mockReturnValue(5600);
middle(10, 10);
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
});
});
describe("우클릭 · 고르기 키워드(마-2)", () => {
it("우클릭 = 쉴 때 앞 명령 반복 · 명령 중 Enter(고르기 끝)", () => {
typeEnter("DRAWORDER");
expect(toolType()).toBe(Tool.DRAWORDER_FRONT);
key("Escape");
expect(toolType()).toBe(Tool.SELECT);
rightClick();
expect(toolType()).toBe(Tool.DRAWORDER_FRONT);
expect(selectingActor()).not.toBeNull();
click(50, 0);
rightClick(); // 고르기 끝 → 명령 확정
expect(selectingActor()?.getSnapshot()?.context?.type).toBe(Tool.SELECT);
expect(toolType()).toBe(Tool.SELECT); // 쉼으로
});
it("고르기 ALL · L(최종) · P(이전) — 앞 고름에 더함 · 「n개를 찾음」", () => {
typeEnter("DRAWORDER");
typeEnter("all");
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
expect(currentNotice()).toBe("2개를 찾음, 총 2개");
key("Escape");
setSelectedEntityIds([]);
typeEnter("DRAWORDER");
typeEnter("L");
expect(getSelectedEntityIds()).toEqual([b]);
key("Escape");
setSelectedEntityIds([]);
rememberSelection([a]);
typeEnter("DRAWORDER");
typeEnter("p");
expect(getSelectedEntityIds()).toEqual([a]);
});
});
describe("ZOOM 덧물음 · 스냅 메뉴(마-2)", () => {
const view = () =>
getScreenCanvasDrawController() as unknown as {
zoomToFitScreen: () => void;
setScreenScale: (s: number) => void;
getScreenScale: () => number;
getCanvasSize: () => Xy;
};
it("Z 물음 줄 · E = 범위 · P = 앞 화면 · Enter = 실시간 안내", () => {
view().zoomToFitScreen = vi.fn(() => view().setScreenScale(4));
typeEnter("z");
expect(currentPrompt().line).toBe(
"윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:",
);
typeEnter("e");
expect(view().getScreenScale()).toBe(4);
expect(toolType()).toBe(Tool.SELECT);
typeEnter("Z");
typeEnter("P");
expect(view().getScreenScale()).toBe(1);
typeEnter("Z");
typeEnter("C");
expect(currentNotice()).toBe("「중심(C)」 옵션은 이 CAD 에 없음.");
key("Enter");
expect(currentNotice()).toBe("실시간 줌은 휠로.");
expect(currentPrompt().kind).toBe("idle");
});
it("2X = 배율 두 배(가운데 붙잡음) · W = 두 구석 · 누름 = 바로 윈도우", () => {
typeEnter("Z");
typeEnter("2x");
expect(view().getScreenScale()).toBe(2);
expect(zoomPrevious()).toBe(true); // 배율 · 원점 되돌림
const size = view().getCanvasSize();
typeEnter("Z");
typeEnter("W");
expect(currentPrompt().line).toBe("첫 번째 구석 지정:");
click(0, 0);
expect(currentPrompt().line).toBe("반대 구석 지정:");
click(100, 50);
expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 100, size.y / 50), 6);
expect(getSelectedEntityIds()).toEqual([]); // 고르기로 안 샘
zoomPrevious();
typeEnter("Z");
click(0, 0);
click(50, 100);
expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 50, size.y / 100), 6);
zoomPrevious();
});
it("'Z 투명 — 선 그리는 중 2X 뒤 선 이어 감", () => {
lineTool();
click(300, 300);
typeEnter("'z");
typeEnter("2X");
expect(view().getScreenScale()).toBe(2);
expect(toolType()).toBe(Tool.LINE);
zoomPrevious();
});
it("Shift+우클릭 = 점 물음에서만 스냅 메뉴 → 한 번 스냅", () => {
rightClick(true); // 쉼 — 메뉴 없음(앞 명령 반복)
expect(isSnapMenuOpen()).toBe(false);
key("Escape"); // 반복된 명령 끝
lineTool();
click(300, 300);
rightClick(true);
expect(isSnapMenuOpen()).toBe(true);
pickSnapMenu("END");
expect(isSnapMenuOpen()).toBe(false);
expect(getSnapOverride()).toBe("END");
click(96, 4);
expectXy(lastSeg().end, 100, 0);
});
});