/** * 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); }); });