/** * 글자 더블클릭 = 그 자리 입력 칸(PLAN 293-1) — prompt 대신 글자 위 입력 칸 · Enter 적용 · Esc 취소 · * 칸 밖 누름 적용 · 되돌리기 한 번 · 여러 줄(MTEXT)은 Ctrl+Enter 적용 · 키가 CAD 로 새지 않음. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-inplace-edit */ import { Point } from "@flatten-js/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../B07_DesignDetail/openwebcad/src/theme.ts", () => ({ paintColor: (color: string) => color, })); vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({ ...(await importOriginal>()), getScreenCanvasDrawController: () => ({ getScreenScale: () => 2, worldToTarget: (point: Point) => point, // 시험 쪽 Point 로 — 앱과 시험이 flatten 을 따로 실어 instanceof 가 갈림 targetToWorld: (point: Point) => new Point(point.x, point.y), measureTextScreen: () => 40, zoomToFitScreen: () => {}, }), })); import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts"; import { closeTextEditor, editorKeyAction, handleTextDoubleClick, } from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit"; import { getEntities, resetUndoBaseline, setEntities, undo, } from "../../../B07_DesignDetail/openwebcad/src/state"; type Listener = (evt: Record) => void; class FakeElement { public value = ""; public className = ""; public style: Record = {}; public selected = false; public removed = false; public listeners: Record = {}; constructor(public tagName: string) {} setAttribute() {} addEventListener(type: string, listener: Listener) { (this.listeners[type] ??= []).push(listener); } fire(type: string, evt: Record = {}) { for (const listener of this.listeners[type] ?? []) listener(evt); } focus() {} select() { this.selected = true; } remove() { this.removed = true; this.fire("blur"); } } let created: FakeElement[] = []; let documentListeners: Record = {}; const prompt = vi.fn(); const canvas = { getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 520, right: 810 }), } as unknown as HTMLCanvasElement; beforeEach(() => { created = []; documentListeners = {}; prompt.mockReset(); vi.stubGlobal("window", { prompt, dispatchEvent: () => true }); vi.stubGlobal("requestAnimationFrame", () => 1); vi.stubGlobal("cancelAnimationFrame", () => {}); vi.stubGlobal("document", { createElement: (tag: string) => { const element = new FakeElement(tag); created.push(element); return element; }, body: { appendChild: () => {} }, addEventListener: (type: string, listener: Listener) => (documentListeners[type] ??= []).push(listener), removeEventListener: (type: string, listener: Listener) => (documentListeners[type] = (documentListeners[type] ?? []).filter((l) => l !== listener)), }); }); afterEach(() => { closeTextEditor(false); vi.unstubAllGlobals(); }); /** 화면 좌표 = 월드 좌표(가짜 제어기) · 캔버스 아래 520 기준으로 y 를 뒤집음 */ const dblclickAt = (x: number, y: number) => handleTextDoubleClick({ clientX: 10 + x, clientY: 520 - y } as MouseEvent, canvas); const key = (editor: FakeElement, k: string, extra: Record = {}) => { const evt = { key: k, ctrlKey: false, metaKey: false, isComposing: false, keyCode: 0, stopped: false, prevented: false, stopPropagation() { evt.stopped = true; }, preventDefault() { evt.prevented = true; }, ...extra, }; editor.fire("keydown", evt); return evt; }; const labels = () => getEntities().map((entity) => (entity as TextEntity).getLabel()); const load = (...entities: TextEntity[]) => { setEntities(entities, false); resetUndoBaseline(); }; describe("글자 더블클릭 = 그 자리 입력 칸", () => { it("더블클릭 → 글자 자리에 입력 칸 · 글 전체 골라짐 · prompt 안 부름 · 캔버스 글자 감춤", () => { const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }); load(text); dblclickAt(101, 200); expect(prompt).not.toHaveBeenCalled(); expect(created).toHaveLength(1); const [editor] = created; expect(editor.tagName).toBe("input"); expect(editor.value).toBe("배수관"); expect(editor.selected).toBe(true); expect(editor.style.left).toBe("110px"); // 캔버스 왼쪽 10 + 글자 x 100 expect(editor.style.top).toBe("320px"); // 캔버스 아래 520 - 글자 y 200 expect(editor.style.font).toContain("10px"); // 글 크기 5 × 배율 2 expect(text.previewLabel).toBe(""); }); it("Enter = 적용 · 되돌리기 한 번에 옛 글 · 키가 CAD 로 새지 않음", () => { load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 })); dblclickAt(100, 200); const [editor] = created; const typed = key(editor, "a"); expect(typed.stopped).toBe(true); expect(typed.prevented).toBe(false); editor.value = "횡단배수관"; const enter = key(editor, "Enter"); expect(enter.stopped).toBe(true); expect(editor.removed).toBe(true); expect(labels()).toEqual(["횡단배수관"]); expect((getEntities()[0] as TextEntity).previewLabel).toBeNull(); undo(); expect(labels()).toEqual(["배수관"]); }); it("Esc = 취소 · 글 그대로 · 감춘 글자 되살림", () => { const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }); load(text); dblclickAt(100, 200); const [editor] = created; editor.value = "바꿈"; key(editor, "Escape"); expect(editor.removed).toBe(true); expect(labels()).toEqual(["배수관"]); expect(getEntities()[0]).toBe(text); expect(text.previewLabel).toBeNull(); }); it("칸 밖 누름 = 적용 · 가운데 단추(팬)는 닫지 않음", () => { load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 })); dblclickAt(100, 200); const [editor] = created; editor.value = "옹벽"; for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 1 }); expect(editor.removed).toBe(false); for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 0 }); expect(editor.removed).toBe(true); expect(labels()).toEqual(["옹벽"]); }); it("한글 조합 중 Enter 는 적용하지 않음", () => { expect(editorKeyAction({ key: "Enter", isComposing: true } as KeyboardEvent, false)).toBeNull(); expect(editorKeyAction({ key: "Enter", keyCode: 229 } as KeyboardEvent, false)).toBeNull(); }); it("여러 줄(MTEXT) = 여러 줄 칸 · Enter 줄바꿈 · Ctrl+Enter 적용 · 줄 늘면 아래로 · 되돌리기 한 번", () => { const first = new TextEntity("0", "첫 줄", new Point(100, 200), { fontSize: 5 }); const second = new TextEntity("0", "둘째 줄", new Point(100, 193), { fontSize: 5 }); first.groupId = second.groupId = "m1"; load(second, first); dblclickAt(100, 193); const [editor] = created; expect(editor.tagName).toBe("textarea"); expect(editor.value).toBe("첫 줄\n둘째 줄"); expect(key(editor, "Enter").prevented).toBe(false); expect(editor.removed).toBe(false); editor.value = "첫 줄\n둘째\n셋째"; key(editor, "Enter", { ctrlKey: true }); expect(editor.removed).toBe(true); const lines = getEntities() as TextEntity[]; expect(lines.map((line) => line.getLabel())).toEqual(["둘째", "셋째", "첫 줄"]); expect(lines.every((line) => line.groupId === "m1")).toBe(true); expect(lines[1].getFirstPoint()?.y).toBeCloseTo(186); undo(); expect(labels()).toEqual(["둘째 줄", "첫 줄"]); }); it("수량표 글자는 칸을 띄우지 않음", () => { load(new TextEntity("b08-cross-table", "12.5", new Point(100, 200), { fontSize: 5 })); dblclickAt(100, 200); expect(created).toHaveLength(0); expect(prompt).not.toHaveBeenCalled(); }); });