- 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
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|