Files
Aislo/resources/tester/cad/text-frame.test.ts
T
eomsangdonandClaude Opus 5.5 cdfd2c6663 feat(487 CAD 글 테두리 · 리본 켜짐): 스침 테두리 = 더블클릭 입력 칸 한 벌(text-frame) · 잡는 범위 = 테두리 안 · 리본 켜짐 하나만
- 새 helpers/text-frame.ts — 정렬 · 가운데 맞춤 · 실제 글꼴 폭(굵게 · 기울임) · 회전 · 여러 줄 묶음 · 도각 칸 = 테두리 한 셈
- TextEntity 스침 · 고름 테두리 = 그 네 귀(여러 줄은 묶음 하나) · distanceTo = 테두리 안 0 · getBoundingBox 그대로
- 입력 칸 자리 · 폭 · 높이 = 같은 셈(여유 = 안쪽 여백) · 더블클릭 범위 = 테두리 + 집기 반경
- measureTextScreen 글꼴 받음 · 디버그 창구 textFrame(화면 네 귀)
- 리본 스침 = 바탕만(hover 기기만) · 켜짐 = 테두리 + 밑줄 · 누른 뒤 blur(초점 테두리 남음 막음)
- 시험 text-frame.test.ts 새로 · text-inplace-edit.test.ts 칸 폭 · 여백 · test_cad_host_tabs 줄바꿈 무관

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:25:55 +09:00

195 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 글 테두리 한 벌(PLAN 487 · `helpers/text-frame.ts`) — 스침 · 고름 테두리 네 귀 = 더블클릭 입력 칸 네 귀
* (왼 · 가운데 · 오른 · 90° 돈 글 · 굵게 · 여러 줄 묶음 · 도각 칸) · 잡는 범위 = 테두리 안.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-frame
*/
import { Point, Vector } from "@flatten-js/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const SCALE = 2;
const fonts: string[] = [];
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: () => SCALE,
worldToTarget: (point: Point) => new Point(point.x * SCALE, point.y * SCALE),
targetToWorld: (point: Point) => new Point(point.x / SCALE, point.y / SCALE),
// 글자당 0.5 em · 굵게는 1.2 배 — 굵게가 폭 재기에 들어가는지 봄
measureTextScreen: (label: string, px: number, font: { bold?: boolean } = {}) => {
fonts.push(font.bold ? "bold" : "normal");
return label.length * px * 0.5 * (font.bold ? 1.2 : 1);
},
zoomToFitScreen: () => {},
}),
}));
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
import {
closeTextEditor,
handleTextDoubleClick,
openTextEditor,
} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit";
import {
resetUndoBaseline,
setEntities,
setHighlightedEntityIds,
} from "../../../B07_DesignDetail/openwebcad/src/state";
class FakeElement {
public value = "";
public className = "";
public style: Record<string, string> = {};
constructor(public tagName: string) {}
setAttribute() {}
addEventListener() {}
focus() {}
select() {}
remove() {}
}
let created: FakeElement[] = [];
const BOUNDS = { left: 10, top: 20, bottom: 520, right: 810 };
const canvas = { getBoundingClientRect: () => BOUNDS } as unknown as HTMLCanvasElement;
beforeEach(() => {
created = [];
fonts.length = 0;
vi.stubGlobal("window", { 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: () => {},
removeEventListener: () => {},
});
});
afterEach(() => {
closeTextEditor(false);
setHighlightedEntityIds([]);
vi.unstubAllGlobals();
});
type Xy = [number, number];
/** 월드 → 화면(px) — 캔버스 왼쪽 · 아래 기준 · y 뒤집음 */
const toScreen = (p: Point): Xy => [BOUNDS.left + p.x * SCALE, BOUNDS.bottom - p.y * SCALE];
/** 스침 테두리 네 귀(화면) — 글을 강조한 채 그려 dash 선 네 개의 시작점 */
function outlineCorners(entities: TextEntity[]): Xy[] {
setEntities(entities, false);
resetUndoBaseline();
setHighlightedEntityIds(entities.map((entity) => entity.id));
const starts: Point[] = [];
const controller = {
setLineStyles: () => {},
drawText: () => {},
drawLine: (start: Point) => starts.push(start),
};
for (const entity of entities) entity.draw(controller as never);
return starts.map(toScreen);
}
/** 입력 칸 네 귀(화면) — left · top + rotate · translate · 폭 · 높이 */
function editorCorners(editor: FakeElement): Xy[] {
const px = (value: string) => Number.parseFloat(value);
const m = /rotate\(([-\d.e]+)rad\) translate\(([-\d.e]+)px, ([-\d.e]+)px\)/.exec(
editor.style.transform,
);
if (!m) throw new Error(editor.style.transform);
const [angle, tx, ty] = [Number(m[1]), Number(m[2]), Number(m[3])];
const [w, h] = [px(editor.style.width), px(editor.style.height)];
const [ox, oy] = [px(editor.style.left), px(editor.style.top)];
const at = (x: number, y: number): Xy => [
ox + x * Math.cos(angle) - y * Math.sin(angle),
oy + x * Math.sin(angle) + y * Math.cos(angle),
];
return [at(tx, ty), at(tx + w, ty), at(tx + w, ty + h), at(tx, ty + h)];
}
const expectSame = (a: Xy[], b: Xy[]) => {
expect(a).toHaveLength(4);
a.forEach(([x, y], i) => {
expect(Math.abs(x - b[i][0])).toBeLessThanOrEqual(1);
expect(Math.abs(y - b[i][1])).toBeLessThanOrEqual(1);
});
};
const text = (label: string, options: Record<string, unknown> = {}, at = new Point(100, 200)) =>
new TextEntity("0", label, at, { fontSize: 5, ...options });
describe("스침 테두리 = 더블클릭 입력 칸", () => {
it.each([
["왼", { textAlign: "left" }],
["가운데", { textAlign: "center" }],
["오른", { textAlign: "right" }],
["90° 돈 글", { textDirection: new Vector(0, 1) }],
["30° 돈 오른 정렬", { textAlign: "right", textDirection: new Vector(Math.sqrt(3), 1) }],
["굵게", { bold: true }],
])("%s — 네 귀 차 ≤ 1 px", (_name, options) => {
const entity = text("STA. 0+000", options);
const outline = outlineCorners([entity]);
openTextEditor([entity], canvas);
expectSame(outline, editorCorners(created[0]));
});
it("굵게 = 굵은 글꼴로 폭을 잼 · 테두리 폭이 넓어짐", () => {
const plain = outlineCorners([text("배수관")]);
const bold = outlineCorners([text("배수관", { bold: true })]);
expect(fonts).toContain("bold");
expect(bold[1][0] - bold[0][0]).toBeGreaterThan(plain[1][0] - plain[0][0]);
});
it("가운데 정렬 = 기준점이 테두리 한가운데 · 위아래 같음", () => {
const [lt, rt, , lb] = outlineCorners([text("배수관")]);
const [bx, by] = toScreen(new Point(100, 200));
expect((lt[0] + rt[0]) / 2).toBeCloseTo(bx);
expect((lt[1] + lb[1]) / 2).toBeCloseTo(by);
});
it("여러 줄 = 묶음 테두리 하나(첫 줄이 그림) = 여러 줄 칸", () => {
const first = text("첫 줄", {}, new Point(100, 200));
const second = text("둘째 줄 길게", {}, new Point(100, 193));
first.groupId = second.groupId = "m1";
const outline = outlineCorners([second, first]);
openTextEditor([first, second], canvas);
expect(created[0].tagName).toBe("textarea");
expectSame(outline, editorCorners(created[0]));
});
it("도각 칸 글 = 칸이 테두리 · 입력 칸도 칸 크기", () => {
const entity = text("{{공사명}}", { boxWidth: 60, boxHeight: 8 });
const outline = outlineCorners([entity]);
expect(outline[1][0] - outline[0][0]).toBeCloseTo(60 * SCALE);
openTextEditor([entity], canvas);
expectSame(outline, editorCorners(created[0]));
});
});
describe("잡는 범위 = 테두리 안", () => {
it("테두리 안 = 0 · 밖 = 테두리까지 거리", () => {
const entity = text("STA. 0+000", { textAlign: "left" });
// 폭 = 10 자 × 10 px × 0.5 = 50 px = 25 월드 · 여유 7.5 px = 3.75 월드
expect(entity.distanceTo(new Point(124, 201))?.[0]).toBe(0);
expect(entity.distanceTo(new Point(130, 200))?.[0]).toBeCloseTo(130 - (125 + 3.75));
});
it("끝 글자 더블클릭도 칸을 띄움 · 테두리 + 집기 반경 밖은 안 띄움", () => {
setEntities([text("STA. 0+000", { textAlign: "left" })], false);
const dblclickAt = (x: number, y: number) =>
handleTextDoubleClick(
{ clientX: BOUNDS.left + x * SCALE, clientY: BOUNDS.bottom - y * SCALE } as MouseEvent,
canvas,
);
dblclickAt(140, 200); // 테두리 오른끝 128.75 + 반경 5 밖
expect(created).toHaveLength(0);
dblclickAt(124, 200); // 끝 글자
expect(created).toHaveLength(1);
});
});