/** * 337 나-2 — 구성선 · 글자 · 해치 넷(XLINE · TEXT · MTEXT · HATCH)을 오토캐드 물음 차례대로. * 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-1 · 6-3 나 줄(XLINE 뒤 넷). * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-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 { cancelActiveCommand } from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input"; import { getSelectedEntityIds } from "../../../B07_DesignDetail/openwebcad/src/state"; import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; import { activeTool, click, entities, initIdle, key, noticeLine, promptLine, type, typeEnter, typed, } from "./helpers/cad-drive"; type Xy = { x: number; y: number }; // biome-ignore lint/suspicious/noExplicitAny: 앱 객체 속을 숫자로만 읽음 type Any = any; const enter = () => key("Enter"); const close = (a: number, b: number) => expect(a).toBeCloseTo(b, 6); const ends = (line: Any): [Xy, Xy] => [line.getStartPoint(), line.getEndPoint()]; const texts = () => entities("Text").map((text: Any) => ({ label: text.getLabel() as string, at: text.basePoint as Xy, size: text.getTextOptions().fontSize as number, dir: text.getTextOptions().textDirection as Xy, })); const idle = () => expect(activeTool()).toBe(Tool.SELECT); beforeEach(() => { initIdle(); remember("XLINE.ANGLE", 0); remember("TEXTSIZE", 2.5); remember("TEXT.ANGLE", 0); }); describe("XLINE(XL)", () => { it("첫 물음 · H 되풀이 · Enter 끝", () => { type("xl"); expect(promptLine()).toBe("점 지정 또는 [수평(H)/수직(V)/각도(A)]:"); type("h"); expect(promptLine()).toBe("통과점을 지정:"); type("0,10"); type("0,20"); expect(promptLine()).toBe("통과점을 지정:"); enter(); idle(); const lines = entities("Line").map(ends); expect(lines).toHaveLength(2); lines.forEach(([a, b], index) => { close(a.y, 10 * (index + 1)); close(b.y, 10 * (index + 1)); expect(Math.abs(b.x - a.x)).toBeGreaterThan(1000); }); }); it("V · Esc = 끝(그은 선 남음)", () => { type("xl"); type("v"); type("5,0"); key("Escape"); idle(); const [[a, b]] = entities("Line").map(ends); close(a.x, 5); close(b.x, 5); }); it("A — 각도 물음 · 기억 · 통과점", () => { type("xl"); type("a"); expect(promptLine()).toBe("X선의 각도 입력 <0>:"); type("45"); expect(promptLine()).toBe("통과점을 지정:"); type("0,0"); enter(); type("xl"); type("a"); expect(promptLine()).toBe("X선의 각도 입력 <45>:"); enter(); // 45 type("10,0"); enter(); idle(); const lines = entities("Line").map(ends); expect(lines).toHaveLength(2); for (const [a, b] of lines) close((b.y - a.y) / (b.x - a.x), 1); const [, [a2]] = lines; close(a2.y - 0, a2.x - 10); // (10, 0) 을 지남 }); it("두 점 방향 되풀이 · 없는 옵션 안내 · 첫 통과점 Enter = 그냥 끝", () => { type("xl"); type("b"); expect(noticeLine()).toBe("「이등분(B)」 옵션은 이 CAD 에 없음."); type("0,0"); type("10,10"); type("10,-10"); enter(); idle(); const slopes = entities("Line") .map(ends) .map(([a, b]) => (b.y - a.y) / (b.x - a.x)); expect(slopes).toHaveLength(2); close(slopes[0], 1); close(slopes[1], -1); type("xl"); type("h"); enter(); idle(); expect(entities("Line")).toHaveLength(2); }); }); describe("TEXT(DT)", () => { it("시작점 → 높이 → 각 → 줄 이어 치기 → 빈 줄 끝", () => { type("dt"); expect(promptLine()).toBe("문자의 시작점 지정:"); type("s"); expect(noticeLine()).toBe("「스타일(S)」 옵션은 이 CAD 에 없음."); type("0,0"); expect(promptLine()).toBe("높이 지정 <2.5000>:"); type("-2"); expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); type("5"); expect(promptLine()).toBe("문자의 회전 각도 지정 <0>:"); enter(); expect(promptLine()).toBe("문자 입력:"); typeEnter("ab c"); // 글 물음의 Space = 빈칸 expect(promptLine()).toBe("문자 입력:"); typeEnter("de"); enter(); // 빈 줄 = 끝 idle(); const [first, second] = texts(); expect([first.label, second.label]).toEqual(["ab c", "de"]); expect([first.size, second.size]).toEqual([5, 5]); close(first.at.x, 0); close(first.at.y, 0); close(second.at.x, 0); close(second.at.y, -5 * 1.35); }); it("높이 기억 · 시작점 Enter = 앞 글 아래(높이 · 각 안 물음) · 처음 줄 빈 Enter = 아무것도 없이 끝", () => { type("dt"); type("0,0"); type("4"); enter(); typeEnter("one"); enter(); type("dt"); enter(); // 시작점 Enter expect(promptLine()).toBe("문자 입력:"); typeEnter("two"); enter(); const [one, two] = texts(); expect(two.size).toBe(4); close(two.at.x, one.at.x); close(two.at.y, one.at.y - 4 * 1.35); type("dt"); type("50,50"); expect(promptLine()).toBe("높이 지정 <4.0000>:"); enter(); enter(); enter(); // 첫 줄에서 빈 Enter idle(); expect(texts()).toHaveLength(2); }); it("회전 각 — 글 방향 · 다음 줄은 돌린 아래 · Esc = 치던 줄 버림", () => { type("dt"); type("10,0"); type("2"); type("90"); typeEnter("up"); typeEnter("next"); typed("zz"); key("Escape"); idle(); const [up, next] = texts(); expect(texts()).toHaveLength(2); close(up.dir.x, 0); close(up.dir.y, 1); close(next.at.x, 10 + 2 * 1.35); close(next.at.y, 0); type("dt"); expect(promptLine()).toBe("문자의 시작점 지정:"); type("0,0"); expect(promptLine()).toBe("높이 지정 <2.0000>:"); enter(); expect(promptLine()).toBe("문자의 회전 각도 지정 <90>:"); key("Escape"); }); }); describe("MTEXT(T · MT)", () => { it("첫 구석 → H → 반대 구석 → 글(\\P 줄바꿈) · 상자 왼쪽 위에서", () => { type("t"); expect(promptLine()).toBe("첫 번째 구석 지정:"); type("0,100"); expect(promptLine()).toBe("반대 구석 지정 또는 [높이(H)/폭(W)]:"); type("c"); expect(noticeLine()).toBe("「열(C)」 옵션은 이 CAD 에 없음."); type("h"); expect(promptLine()).toBe("높이 지정 <2.5000>:"); type("4"); expect(promptLine()).toBe("반대 구석 지정 또는 [높이(H)/폭(W)]:"); type("50,60"); expect(promptLine()).toBe("문자 입력 (줄바꿈 \\P):"); typeEnter("a\\Pb"); idle(); const [a, b] = texts(); expect([a.label, b.label]).toEqual(["a", "b"]); expect(a.size).toBe(4); close(a.at.x, 0); close(a.at.y, 96); close(b.at.y, 96 - 4 * 1.35); }); it("W — 폭 숫자 → 글 · 높이 = 기억(TEXTSIZE) · 빈 글 = 안 만들고 끝", () => { type("mt"); type("10,10"); type("w"); expect(promptLine()).toBe("폭 지정:"); type("30"); expect(promptLine()).toBe("문자 입력 (줄바꿈 \\P):"); typeEnter("x"); idle(); const [x] = texts(); close(x.at.x, 10); close(x.at.y, 10 - 2.5); type("t"); type("0,0"); type("5,5"); enter(); idle(); expect(texts()).toHaveLength(1); }); }); describe("HATCH(H · BH)", () => { const boxes = () => { for (const [a, b] of [ ["0,0", "100,100"], ["40,40", "60,60"], ]) { type("rec"); type(a); type(b); cancelActiveCommand(); } expect(entities()).toHaveLength(2); }; const spans = () => entities("Hatch").map((hatch: Any) => { const xs = (hatch.getPoints() as Xy[]).map((point) => point.x); return [Math.min(...xs), Math.max(...xs)]; }); beforeEach(boxes); it("내부 점 = 품은 것 중 가장 작은 경계 · 되풀이 · 같은 경계 한 번 · Enter = 만듦", () => { type("h"); expect(promptLine()).toBe("내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:"); click(50, 50); click(10, 10); click(15, 15); expect(promptLine()).toBe("내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:"); enter(); idle(); expect(spans()).toEqual([ [40, 60], [0, 100], ]); }); it("U = 마지막 경계 빼기 · 경계 없는 점은 무시 · Esc = 안 만듦", () => { type("h"); click(50, 50); click(10, 10); click(500, 500); type("u"); enter(); expect(spans()).toEqual([[40, 60]]); type("h"); click(10, 10); key("Escape"); idle(); expect(spans()).toHaveLength(1); type("bh"); type("t"); expect(noticeLine()).toBe("「설정(T)」 옵션은 이 CAD 에 없음."); enter(); // 경계 없이 Enter = 그냥 끝 idle(); expect(spans()).toHaveLength(1); }); it("S — 객체 선택 → Enter = 고른 객체 경계로 만듦 · 고름 풂", () => { type("h"); type("s"); expect(promptLine()).toBe("객체 선택:"); click(0, 50); enter(); idle(); expect(spans()).toEqual([[0, 100]]); expect(getSelectedEntityIds()).toEqual([]); }); });