feat(CAD): 337-5 라 옮기기 무리 2 단계 — COPY · MIRROR · STRETCH · ARRAY · PEDIT 고르기

- COPY: 기준점 [변위(D)/모드(O)/다중(M)] · 두 번째 점 [배열(A)] → [배열(A)/종료(E)/명령 취소(U)] <종료> 되풀이 · 첫 빈 Enter = 변위 · COPYMODE 기억 · A 항목 수 · F 맞춤
- MIRROR: 대칭선 두 점 → 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오> · Esc = 안 만듦
- STRETCH: MOVE 틀 + 걸침 창 안 꼭짓점만(선 · 폴리선 토막 · 사각형) · 창 안 통째 · 하나씩 고른 것 = 이동 · STRETCH.DISP
- ARRAY: 유형 [직사각형(R)/원형(PO)] <직사각형>(ARRAYTYPE) → 편집 물음 되풀이(COU · S · COL · R · X / I · A · F · ROT · X) · 미리보기 4 × 3 · 6 개 360° · 처음 간격 범위 1.5 배 · Esc = 안 만듦(N17 없어짐)
- PEDIT: 공용 고르기 상태(객체 선택 · 빈 Enter · Esc = 끝 · 묶은 뒤 끝)
- 시험 transform-acad 29 + ALL · L · P 1(선택 도구 처리 뒤 돎)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 00:25:20 +09:00
co-authored by Claude Opus 5.5
parent d35bdfa69d
commit 1d5cdff0a5
6 changed files with 1260 additions and 799 deletions
+357
View File
@@ -13,7 +13,9 @@ vi.mock("react-toastify", () => ({
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";
@@ -316,3 +318,358 @@ describe("SCALE(1-3)", () => {
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 는 거절 없이 선택 도구로 넘어감", () => {
line("0,0", "10,0");
type("m");
for (const word of ["all", "l", "p"]) {
type(word);
expect(noticeLine()).toBeNull();
expect(activeTool()).toBe(Tool.MOVE);
}
});
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],
]);
},
);
});