Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
/**
|
||
* 글자 더블클릭 = 그 자리 입력 칸(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<Record<string, unknown>>()),
|
||
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<string, unknown>) => void;
|
||
class FakeElement {
|
||
public value = "";
|
||
public className = "";
|
||
public style: Record<string, string> = {};
|
||
public selected = false;
|
||
public removed = false;
|
||
public listeners: Record<string, Listener[]> = {};
|
||
constructor(public tagName: string) {}
|
||
setAttribute() {}
|
||
addEventListener(type: string, listener: Listener) {
|
||
(this.listeners[type] ??= []).push(listener);
|
||
}
|
||
fire(type: string, evt: Record<string, unknown> = {}) {
|
||
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<string, Listener[]> = {};
|
||
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<string, unknown> = {}) => {
|
||
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();
|
||
});
|
||
});
|