/** * 337 라 — 옮기기 무리 7(MOVE · COPY · ROTATE · SCALE · MIRROR · STRETCH · ARRAY) + PEDIT 고르기 · 오토캐드 물음 차례. * 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-3 · 2-14 · 6-3 라 줄. * 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE · 친 좌표)로만 만들고 숫자로 판정. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad transform-acad */ 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 { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt"; import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command"; import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity"; import { selectToolStateMachine } from "../../../B07_DesignDetail/openwebcad/src/tools/select-tool"; import { getGhostHelperEntities, getSelectedEntityIds, setSelectedEntityIds, } from "../../../B07_DesignDetail/openwebcad/src/state"; import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; import { activeTool, click, entities, initIdle, key, move, noticeLine, promptLine, type, } from "./helpers/cad-drive"; type Xy = { x: number; y: number }; const r = (value: number) => Math.round(value * 1000) / 1000 + 0; const ends = (entity: Entity) => { const line = entity as unknown as { getStartPoint(): Xy; getEndPoint(): Xy }; const a = line.getStartPoint(); const b = line.getEndPoint(); return [r(a.x), r(a.y), r(b.x), r(b.y)]; }; /** 선들 끝점(정렬) */ const lineEnds = () => entities("Line") .map(ends) .sort((p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2] || p[3] - q[3]); const enter = () => key("Enter"); const esc = () => key("Escape"); /** 선 하나 그림(쉼으로 돌아옴) */ const line = (a: string, b: string) => { type("l"); type(a); type(b); esc(); }; /** 선 그리고 고름 */ const lineSelected = (a = "0,0", b = "20,0") => { line(a, b); setSelectedEntityIds(entities().map((entity) => entity.id)); }; /** 세션 기억 — 시험마다 처음 값으로 */ const KEYS = [ "MOVE.DISP", "STRETCH.DISP", "COPYMODE", "ROTATE.ANGLE", "ROTATE.REF", "ROTATE.NEW", "SCALE.FACTOR", "SCALE.REF", "SCALE.NEW", "ARRAYTYPE", "ARRAY.COLS", "ARRAY.ROWS", "ARRAY.ITEMS", "ARRAY.FILL", ]; beforeEach(() => { initIdle(); for (const name of KEYS) remember(name, undefined); }); describe("MOVE(1-3)", () => { it("물음 차례 · 두 번째 점 = 기준점에서 변위 · 한 번 뒤 끝 · 고름 풂", () => { lineSelected(); const id = entities()[0].id; type("m"); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:"); type("0,0"); expect(promptLine()).toBe("두 번째 점 지정 또는 <첫 번째 점을 변위로 사용>:"); type("@10,5"); expect(lineEnds()).toEqual([[10, 5, 30, 5]]); expect(entities()[0].id).toBe(id); expect(activeTool()).toBe(Tool.SELECT); expect(getSelectedEntityIds()).toEqual([]); }); it("빈 Enter 두 번째 점 = 첫 점 좌표가 변위", () => { lineSelected(); type("move"); type("3,4"); enter(); expect(lineEnds()).toEqual([[3, 4, 23, 4]]); expect(activeTool()).toBe(Tool.SELECT); }); it("D · 기준점 빈 Enter = <변위> · 변위 기억(MOVE.DISP)", () => { lineSelected(); type("m"); type("d"); expect(promptLine()).toBe("변위 지정 <0.0000, 0.0000>:"); type("10,5"); expect(lineEnds()).toEqual([[10, 5, 30, 5]]); setSelectedEntityIds(entities().map((entity) => entity.id)); type("m"); enter(); // 기준점 빈 Enter = 변위(D) expect(promptLine()).toBe("변위 지정 <10.0000, 5.0000>:"); enter(); // 기억한 변위 expect(lineEnds()).toEqual([[20, 10, 40, 10]]); }); it("Esc = 제자리 끝(기준점 뒤 · 미리보기 중)", () => { lineSelected(); type("m"); type("0,0"); move(50, 50); esc(); expect(lineEnds()).toEqual([[0, 0, 20, 0]]); expect(activeTool()).toBe(Tool.SELECT); expect(getSelectedEntityIds()).toEqual([]); }); it("고른 것 없음 → 「객체 선택:」 · 누름 고름 · Enter → 기준점 · 빈 고르기 Enter = 끝", () => { line("0,0", "20,0"); type("m"); expect(promptLine()).toBe("객체 선택:"); enter(); expect(activeTool()).toBe(Tool.SELECT); type("m"); click(10, 0); enter(); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:"); type("0,0"); type("0,10"); expect(lineEnds()).toEqual([[0, 10, 20, 10]]); }); it("점 묻는 중 명령 이름 = 거절(도구 그대로)", () => { lineSelected(); type("m"); type("0,0"); type("c"); expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); expect(activeTool()).toBe(Tool.MOVE); }); it("N1 — 기준점 뒤 딴 명령 · Ctrl+Z → 원본 제자리 · 두 벌 없음", () => { lineSelected(); const id = entities()[0].id; type("m"); type("0,0"); move(40, 40); runCommandInput("CIRCLE"); expect(activeTool()).toBe(Tool.CIRCLE); expect(lineEnds()).toEqual([[0, 0, 20, 0]]); expect(entities()[0].id).toBe(id); esc(); setSelectedEntityIds([id]); type("m"); type("0,0"); key("z", { ctrlKey: true, code: "KeyZ" }); expect(activeTool()).toBe(Tool.SELECT); expect(entities().filter((entity) => entity.id === id)).toHaveLength( entities().length > 0 ? 1 : 0, ); }); }); describe("ROTATE(1-3)", () => { it("물음 차례 · 숫자 = 반시계 + · 한 번 뒤 끝 · 각 기억", () => { lineSelected("10,0", "20,0"); type("ro"); expect(promptLine()).toBe("기준점 지정:"); type("0,0"); expect(promptLine()).toBe("회전 각도 지정 또는 [복사(C)/참조(R)] <0>:"); type("90"); expect(lineEnds()).toEqual([[0, 10, 0, 20]]); expect(activeTool()).toBe(Tool.SELECT); setSelectedEntityIds(entities().map((entity) => entity.id)); type("ro"); type("0,0"); expect(promptLine()).toBe("회전 각도 지정 또는 [복사(C)/참조(R)] <90>:"); enter(); // 기억한 90 expect(lineEnds()).toEqual([[-10, 0, -20, 0]]); }); it("누름 한 번 = 기준점에서 그 점 방향 각", () => { lineSelected("10,0", "20,0"); type("ro"); type("0,0"); type("-30,30"); // 135° const [x1, y1] = lineEnds()[0]; expect([x1, y1]).toEqual([r(-10 / Math.SQRT2), r(10 / Math.SQRT2)]); }); it("C = 사본 회전(원본 둠)", () => { lineSelected("10,0", "20,0"); type("ro"); type("0,0"); type("c"); expect(activeTool()).toBe(Tool.ROTATE); type("90"); expect(lineEnds()).toEqual([ [0, 10, 0, 20], [10, 0, 20, 0], ]); }); it("R — 참조 두 점(@ = 기준점) → 새 각도 · 참조 숫자 · P 두 점", () => { lineSelected("10,0", "20,0"); type("ro"); type("0,0"); type("r"); expect(promptLine()).toBe("참조 각도를 지정 <0>:"); type("@0,0"); // 기준점 expect(promptLine()).toBe("두 번째 점 지정:"); type("10,10"); // 참조 45° expect(promptLine()).toBe("새 각도 지정 또는 [점(P)] <0>:"); type("90"); // 90 − 45 const s = 10 / Math.SQRT2; expect(lineEnds()).toEqual([[r(s), r(s), r(2 * s), r(2 * s)]]); setSelectedEntityIds(entities().map((entity) => entity.id)); type("ro"); type("0,0"); type("r"); expect(promptLine()).toBe("참조 각도를 지정 <45>:"); type("45"); type("p"); expect(promptLine()).toBe("첫 번째 점 지정:"); type("0,0"); type("-5,0"); // 새 180 → 135 돌림 expect(lineEnds()).toEqual([[-10, 0, -20, 0]]); }); it("Esc = 제자리 끝 · 글 = 각 거절", () => { lineSelected("10,0", "20,0"); type("ro"); type("0,0"); type("abc"); expect(noticeLine()).toBe("유효한 숫자 각도, 두 번째 점 또는 옵션 키워드를 요구함."); move(0, 40); esc(); expect(lineEnds()).toEqual([[10, 0, 20, 0]]); expect(activeTool()).toBe(Tool.SELECT); }); }); describe("SCALE(1-3)", () => { it("물음 차례 · 숫자 비율 · 점 = 거리 · 기억", () => { lineSelected(); type("sc"); expect(promptLine()).toBe("기준점 지정:"); type("0,0"); expect(promptLine()).toBe("축척 비율 지정 또는 [복사(C)/참조(R)] <1.0000>:"); type("2"); expect(lineEnds()).toEqual([[0, 0, 40, 0]]); expect(activeTool()).toBe(Tool.SELECT); setSelectedEntityIds(entities().map((entity) => entity.id)); type("sc"); type("0,0"); expect(promptLine()).toBe("축척 비율 지정 또는 [복사(C)/참조(R)] <2.0000>:"); type("0,0.5"); // 거리 0.5 expect(lineEnds()).toEqual([[0, 0, 20, 0]]); }); it("0 · 음수 거절(도구 그대로) · C = 사본", () => { lineSelected(); type("sc"); type("0,0"); type("0"); expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); type("-2"); expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); expect(activeTool()).toBe(Tool.SCALE); type("c"); type("0.5"); expect(lineEnds()).toEqual([ [0, 0, 10, 0], [0, 0, 20, 0], ]); }); it("R — 참조 숫자 → 새 길이 · 참조 두 점 → P 두 점", () => { lineSelected(); type("sc"); type("0,0"); type("r"); expect(promptLine()).toBe("참조 길이 지정 <1.0000>:"); type("10"); expect(promptLine()).toBe("새 길이 지정 또는 [점(P)] <1.0000>:"); type("5"); expect(lineEnds()).toEqual([[0, 0, 10, 0]]); setSelectedEntityIds(entities().map((entity) => entity.id)); type("sc"); type("0,0"); type("r"); type("0,0"); type("10,0"); // 참조 10 type("p"); type("0,0"); type("0,30"); // 새 30 expect(lineEnds()).toEqual([[0, 0, 30, 0]]); }); }); describe("COPY(1-3)", () => { it("물음 차례 · 되풀이 · U 마지막 사본 되돌림 · 빈 Enter = 끝(사본 남음)", () => { lineSelected(); type("co"); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)] <변위>:"); type("0,0"); expect(promptLine()).toBe("두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:"); type("0,10"); expect(promptLine()).toBe("두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:"); type("0,20"); expect(entities("Line")).toHaveLength(3); type("u"); expect(entities("Line")).toHaveLength(2); expect(activeTool()).toBe(Tool.COPY); enter(); expect(activeTool()).toBe(Tool.SELECT); expect(lineEnds()).toEqual([ [0, 0, 20, 0], [0, 10, 20, 10], ]); }); it("첫 물음 빈 Enter = 기준점 좌표가 변위(한 벌 뒤 끝) · E = 끝 · Esc = 끝(사본 남음)", () => { lineSelected(); type("co"); type("5,5"); enter(); expect(activeTool()).toBe(Tool.SELECT); expect(lineEnds()).toEqual([ [0, 0, 20, 0], [5, 5, 25, 5], ]); setSelectedEntityIds([entities()[0].id]); type("co"); type("0,0"); type("0,-5"); type("e"); expect(activeTool()).toBe(Tool.SELECT); setSelectedEntityIds([entities()[0].id]); type("co"); type("0,0"); type("0,-9"); esc(); expect(entities("Line")).toHaveLength(4); }); it("O 단일(COPYMODE 기억) · 단일 땐 M 보임 · M = 이번만 되풀이", () => { lineSelected(); type("co"); type("o"); expect(promptLine()).toBe("복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:"); type("s"); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:"); type("0,0"); type("0,10"); expect(activeTool()).toBe(Tool.SELECT); // 단일 = 한 벌 뒤 끝 expect(entities("Line")).toHaveLength(2); setSelectedEntityIds([entities()[0].id]); type("co"); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:"); type("m"); type("0,0"); type("0,20"); type("0,30"); expect(activeTool()).toBe(Tool.COPY); expect(entities("Line")).toHaveLength(4); }); it("A 배열 · F 맞춤 · D 변위", () => { lineSelected(); type("co"); type("0,0"); type("a"); expect(promptLine()).toBe("배열할 항목 수 입력:"); type("1.5"); expect(noticeLine()).toBe("정수 값을 요구함."); type("3"); expect(promptLine()).toBe("두 번째 점 지정 또는 [맞춤(F)]:"); type("0,10"); expect(lineEnds().map((ends) => ends[1])).toEqual([0, 10, 20]); type("a"); type("3"); type("f"); type("0,-10"); // 맞춤 — 마지막 항목이 −10 expect(lineEnds().map((ends) => ends[1])).toEqual([-10, -5, 0, 10, 20]); esc(); setSelectedEntityIds([entities()[0].id]); type("co"); type("d"); expect(promptLine()).toBe("변위 지정 <0.0000, 0.0000>:"); type("100,0"); expect(activeTool()).toBe(Tool.SELECT); expect(entities("Line")).toHaveLength(6); }); }); describe("MIRROR(1-3)", () => { it("물음 차례 · 빈 Enter = 아니오(원본 둠) · Y = 원본 지움 · N", () => { lineSelected("0,10", "20,10"); type("mi"); expect(promptLine()).toBe("대칭선의 첫 번째 점 지정:"); type("0,0"); expect(promptLine()).toBe("대칭선의 두 번째 점 지정:"); type("10,0"); expect(promptLine()).toBe("원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:"); enter(); expect(activeTool()).toBe(Tool.SELECT); expect(lineEnds()).toEqual([ [0, -10, 20, -10], [0, 10, 20, 10], ]); setSelectedEntityIds([entities()[0].id]); type("mi"); type("0,0"); type("0,10"); type("y"); expect(lineEnds()).toEqual([ [0, -10, 20, -10], [0, 10, -20, 10], ]); setSelectedEntityIds([entities()[0].id]); type("mi"); type("0,0"); type("0,10"); type("n"); expect(entities("Line")).toHaveLength(3); }); it("Esc = 안 만듦(예/아니오 물음에서도)", () => { lineSelected("0,10", "20,10"); type("mi"); type("0,0"); type("10,0"); esc(); expect(lineEnds()).toEqual([[0, 10, 20, 10]]); expect(activeTool()).toBe(Tool.SELECT); }); }); describe("STRETCH(1-3)", () => { it("걸침 창 안 꼭짓점만 옮김 · 창 밖 객체 그대로", () => { line("0,0", "20,0"); line("100,0", "120,0"); type("s"); expect(promptLine()).toBe("객체 선택:"); click(30, 5); // 걸침(오→왼) click(15, -5); enter(); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:"); type("0,0"); type("@10,0"); expect(lineEnds()).toEqual([ [0, 0, 30, 0], [100, 0, 120, 0], ]); expect(activeTool()).toBe(Tool.SELECT); }); it("창 안에 통째 든 것 = 이동 · D 변위 · 하나씩 고른 것(창 없음) = 이동", () => { line("0,0", "20,0"); type("s"); click(-5, 5); click(25, -5); enter(); type("d"); type("0,7"); expect(lineEnds()).toEqual([[0, 7, 20, 7]]); setSelectedEntityIds([entities()[0].id]); type("s"); type("0,0"); type("3,0"); expect(lineEnds()).toEqual([[3, 7, 23, 7]]); }); }); describe("ARRAY(1-3)", () => { it("R = 직사각형 · 미리보기 4 × 3 · 처음 간격 1.5 배 · 빈 Enter = 만듦", () => { lineSelected("0,0", "10,0"); type("ar"); expect(promptLine()).toBe("배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:"); type("r"); expect(promptLine()).toBe( "그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:", ); expect(getGhostHelperEntities()).toHaveLength(11); enter(); expect(activeTool()).toBe(Tool.SELECT); expect(entities("Line")).toHaveLength(12); expect(lineEnds()[11]).toEqual([45, 30, 55, 30]); }); it("COU · S · COL · R · X", () => { lineSelected("0,0", "10,0"); type("ar"); enter(); // <직사각형> type("cou"); expect(promptLine()).toBe("열 수 입력 <4>:"); type("2"); expect(promptLine()).toBe("행 수 입력 <3>:"); type("2"); type("s"); expect(promptLine()).toBe("열 사이의 거리 지정 <15.0000>:"); type("20"); expect(promptLine()).toBe("행 사이의 거리 지정 <15.0000>:"); type("30"); type("col"); type("3"); expect(promptLine()).toBe("열 사이의 거리 지정 <20.0000>:"); enter(); type("r"); expect(promptLine()).toBe("행 수 입력 <2>:"); type("1"); expect(promptLine()).toBe("행 사이의 거리 지정 <30.0000>:"); enter(); type("x"); expect(lineEnds()).toEqual([ [0, 0, 10, 0], [20, 0, 30, 0], [40, 0, 50, 0], ]); }); it("PO — 중심 · I · F · A · ROT · 유형 기억", () => { lineSelected("10,0", "20,0"); type("ar"); type("po"); expect(promptLine()).toBe("배열의 중심점 지정:"); type("0,0"); expect(promptLine()).toBe( "그립을 선택하여 배열을 편집하거나 [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:", ); expect(getGhostHelperEntities()).toHaveLength(5); // 6 개 360° type("i"); expect(promptLine()).toBe("배열의 항목 수 입력 <6>:"); type("4"); type("x"); expect(lineEnds()).toEqual([ [-10, 0, -20, 0], [0, -10, 0, -20], [0, 10, 0, 20], [10, 0, 20, 0], ]); setSelectedEntityIds([entities()[0].id]); type("ar"); expect(promptLine()).toBe("배열 유형 입력 [직사각형(R)/원형(PO)] <원형>:"); enter(); type("0,0"); type("i"); type("3"); type("f"); expect(promptLine()).toBe("채울 각도 지정(+=ccw, -=cw) <360>:"); type("180"); type("a"); expect(promptLine()).toBe("항목 사이의 각도 지정 <90>:"); type("45"); type("rot"); expect(promptLine()).toBe("배열된 항목을 회전하시겠습니까? [예(Y)/아니오(N)] <예>:"); type("n"); type("x"); // 원본 (10,0)-(20,0) · 가운데 (15,0) 을 45° · 90° 자리로 옮김(돌지 않음) const c45 = 15 / Math.SQRT2; expect(lineEnds()).toContainEqual([r(c45 - 5), r(c45), r(c45 + 5), r(c45)]); expect(lineEnds()).toContainEqual([-5, 15, 5, 15]); }); it("Esc = 안 만듦(편집 물음 · 숫자 물음)", () => { lineSelected("0,0", "10,0"); type("ar"); type("r"); esc(); expect(entities("Line")).toHaveLength(1); expect(getGhostHelperEntities()).toHaveLength(0); setSelectedEntityIds([entities()[0].id]); type("ar"); type("po"); type("0,0"); type("i"); esc(); expect(entities("Line")).toHaveLength(1); expect(activeTool()).toBe(Tool.SELECT); }); }); describe("PEDIT 고르기 · 고르기 공용(2-14 ④ · 2-15)", () => { it("PEDIT — 고른 것 바로 묶음 · 없으면 「객체 선택:」 · 빈 Enter · Esc = 끝", () => { line("0,0", "10,0"); line("10,0", "10,10"); type("pe"); expect(promptLine()).toBe("객체 선택:"); enter(); expect(activeTool()).toBe(Tool.SELECT); type("pe"); esc(); expect(activeTool()).toBe(Tool.SELECT); setSelectedEntityIds(entities().map((entity) => entity.id)); type("pe"); expect(activeTool()).toBe(Tool.SELECT); expect(entities("Line")).toHaveLength(0); expect(entities()).toHaveLength(1); }); it("고르기 물음의 ALL · L · P = 실제 고름 · 「n개를 찾음」 · 다시 「객체 선택:」 · 빈 Enter = 고르기 끝", () => { line("0,0", "10,0"); line("0,20", "10,20"); type("m"); type("all"); expect(noticeLine()).toBe("2개를 찾음, 총 2개"); expect(promptLine()).toBe("객체 선택:"); expect(activeTool()).toBe(Tool.MOVE); type("l"); expect(noticeLine()).toBe("1개를 찾음, 총 2개"); expect(promptLine()).toBe("객체 선택:"); type("p"); expect(noticeLine()).toMatch(/^\d+개를 찾음, 총 2개$/); expect(promptLine()).toBe("객체 선택:"); enter(); expect(activeTool()).toBe(Tool.MOVE); expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:"); }); const selectTakesKeywords = !!( selectToolStateMachine.states.WAITING_FOR_FIRST_SELECT_POINT.config.on as Record< string, unknown > )?.["OPTION.ALL"]; it.skipIf(!selectTakesKeywords)( "ALL · P(앞 고름) · L(마지막 객체) — 마 선택 도구 처리 뒤", () => { line("0,0", "10,0"); line("0,20", "10,20"); type("m"); type("all"); enter(); type("0,0"); type("0,5"); expect(lineEnds()).toEqual([ [0, 5, 10, 5], [0, 25, 10, 25], ]); type("m"); type("p"); enter(); type("0,0"); type("5,0"); expect(lineEnds()).toEqual([ [5, 5, 15, 5], [5, 25, 15, 25], ]); type("m"); type("l"); enter(); type("0,0"); type("0,100"); expect(lineEnds()).toEqual([ [5, 5, 15, 5], [5, 125, 15, 125], ]); }, ); });