- 고르기: 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기 · 상자 = 앞 고름 ∪ 상자 · Shift 상자 = 뺌(H4 · H5) · 명령 안 고르기 단계도 누를 때 첫 점 · 스침 강조(ic/selecting.ts) - 그립: 쉴 때만 집음(N7) · 누르고 떼면 대기 → 다음 누름에 놓음 · 끌어 놓기(H3) · 거리 숫자(N8) · 확정 · 읽기 전용 도면은 그립 · Delete 막음(H2) - 직교: F8 = 기준점에서 가로 · 세로 강제 · Shift 누른 동안 뒤집음 · 객체 스냅 점 · 좌표 · 각도 잠금은 안 덮음 · 클릭 사건 snapped 채움(ic/ortho.ts · H8) · 가이드는 F3 와 뗌 · 가이드 잡는 거리 = 화면 픽셀(H9) - 휠: 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 모든 화면 · ☰ 설정 뒤집기 그대로) · 가운데 단추 두 번 = 범위 줌 · ZOOM · ⛶ 앞 화면 기억(ic/zoom-prompt.ts) - 시험: mouse-acad 새로 · 휠 방향 시험 손질(input-wheel · input-prefs-cad · input_prefs · app_settings · 164 · 220_1 · 221_1 · 244_8 기대값 뒤집음 · 266 pull 박음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
||
* CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마).
|
||
* 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 ·
|
||
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂.
|
||
* 시험 쪽 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 { 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 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 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("그립 끌어 놓음 = 놓은 자리", () => {
|
||
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);
|
||
press(10, 10, { button: 1 });
|
||
now.mockReturnValue(1300);
|
||
press(11, 11, { button: 1 });
|
||
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
|
||
expect(view.getScreenScale()).toBe(3);
|
||
expect(zoomPrevious()).toBe(true);
|
||
expect(view.getScreenScale()).toBe(1);
|
||
now.mockReturnValue(5000);
|
||
press(10, 10, { button: 1 });
|
||
now.mockReturnValue(5600);
|
||
press(10, 10, { button: 1 });
|
||
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|