662 lines
28 KiB
TypeScript
662 lines
28 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import type { AnnotationData, ShapeData } from "@/db/schema";
|
||
import {
|
||
STAMP_FONT_RATIO,
|
||
STAMP_HEIGHT_RATIO,
|
||
STAMP_MAX_LINES,
|
||
STAMP_TEXT_WIDTH_RATIO,
|
||
annotationBBox,
|
||
clampStampIntoPage,
|
||
hitTestPath,
|
||
hitTestShape,
|
||
rotatePoint,
|
||
rotatedRectAABB,
|
||
scaleAnnotationData,
|
||
stampBoxHeight,
|
||
stampFitFontSize,
|
||
stampLayout,
|
||
stampRect,
|
||
textboxMaxWidthPct,
|
||
translateAnnotationData,
|
||
wrapStampLabel,
|
||
} from "@/utils/geometry";
|
||
|
||
// เคล็ดลับจาก docstring ของ annotationBBox: size 100×100 ทำให้ px = % โดยตรง
|
||
const S = { width: 100, height: 100 };
|
||
|
||
describe("annotationBBox", () => {
|
||
it("HIGHLIGHT: union ของ rects ทุกอัน", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "HIGHLIGHT",
|
||
data: {
|
||
text: "ทดสอบ",
|
||
color: "#fde047",
|
||
rects: [
|
||
{ x: 10, y: 20, width: 30, height: 5 },
|
||
{ x: 45, y: 22, width: 20, height: 5 },
|
||
],
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 10, y: 20, width: 55, height: 7 });
|
||
});
|
||
|
||
it("HIGHLIGHT: rects ว่าง → กล่องศูนย์", () => {
|
||
const bbox = annotationBBox(
|
||
{ type: "HIGHLIGHT", data: { text: "", color: "#000", rects: [] } },
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 0, y: 0, width: 0, height: 0 });
|
||
});
|
||
|
||
it("DRAW: ขยายจุดสุดขอบด้วย strokeWidth/2", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "DRAW",
|
||
data: {
|
||
strokeColor: "#ef4444",
|
||
strokeWidth: 2,
|
||
paths: [
|
||
{ x: 10, y: 10 },
|
||
{ x: 30, y: 50 },
|
||
],
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 9, y: 9, width: 22, height: 42 });
|
||
});
|
||
|
||
it("COMMENT: กล่องสี่เหลี่ยมรอบ position ขนาด COMMENT_PIN_BOX_PCT", () => {
|
||
const bbox = annotationBBox(
|
||
{ type: "COMMENT", data: { position: { x: 50, y: 50 }, message: "สวัสดี" } },
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 48, y: 48, width: 4, height: 4 });
|
||
});
|
||
|
||
it("SHAPE rect: ขยายออกด้วยครึ่งความหนาเส้น", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "SHAPE",
|
||
data: {
|
||
shape: "rect",
|
||
rect: { x: 10, y: 20, width: 30, height: 10 },
|
||
strokeColor: "#000",
|
||
strokeWidth: 2,
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 9, y: 19, width: 32, height: 12 });
|
||
});
|
||
|
||
it("SHAPE line: bbox ของ from/to ขยายครึ่งความหนา (ไม่ใช่ rect)", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "SHAPE",
|
||
data: {
|
||
shape: "line",
|
||
from: { x: 10, y: 10 },
|
||
to: { x: 40, y: 20 },
|
||
strokeColor: "#000",
|
||
strokeWidth: 1,
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 9.5, y: 9.5, width: 31, height: 11 });
|
||
});
|
||
|
||
it("REDACTION: rect เดิมตรง ๆ ไม่ขยายความหนา", () => {
|
||
const bbox = annotationBBox(
|
||
{ type: "REDACTION", data: { rect: { x: 10, y: 20, width: 30, height: 10 } } },
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 10, y: 20, width: 30, height: 10 });
|
||
});
|
||
|
||
it("TEXTBOX: ประมาณจากจำนวนตัวอักษร × 0.6 fontSize บรรทัดสูง 1.5 fontSize", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: { position: { x: 10, y: 20 }, text: "อบจ", color: "#111827", fontSize: 2 },
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.x).toBe(10);
|
||
expect(bbox.y).toBe(20);
|
||
expect(bbox.width).toBeCloseTo(3.6); // 3 ตัว × (2 × 0.6)
|
||
expect(bbox.height).toBeCloseTo(3); // 1.5 × 2
|
||
});
|
||
|
||
it("TEXTBOX: ข้อความกว้างเกิน 50% ของหน้า → wrap เป็นหลายบรรทัด กว้างถูก cap", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: { position: { x: 0, y: 0 }, text: "x".repeat(60), color: "#000", fontSize: 2 },
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.width).toBe(50); // 60 × 1.2 = 72 → cap ที่ 50
|
||
expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3
|
||
});
|
||
|
||
it("TEXTBOX: กล่องใกล้ขอบขวา → bbox ถูก clamp ไม่ยื่นเกินขอบหน้า (เหมือนที่ render บีบ)", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: { position: { x: 95, y: 50 }, text: "x".repeat(60), color: "#000", fontSize: 2 },
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.x).toBe(95);
|
||
expect(bbox.width).toBe(5); // ค่าประมาณกว้าง 50 แต่เหลือพื้นที่แค่ 100 − 95 → clamp ที่ 5
|
||
});
|
||
|
||
it("TEXTBOX: align ไม่ขยับกล่อง — position เป็นมุมซ้ายบนเสมอ (ขยับแค่ข้อความข้างใน)", () => {
|
||
for (const align of ["left", "center", "right"] as const) {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: {
|
||
position: { x: 30, y: 20 },
|
||
text: "x".repeat(60),
|
||
color: "#000",
|
||
fontSize: 2,
|
||
align,
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.x).toBe(30); // ขอบซ้ายกล่อง = position.x ทุก align
|
||
expect(bbox.width).toBe(50); // cap เดิม min(50, 100−30=70)
|
||
}
|
||
});
|
||
|
||
it("textboxMaxWidthPct: ความกว้างสูงสุด = พื้นที่จริงถึงขอบขวา (cap 50%)", () => {
|
||
expect(textboxMaxWidthPct(30)).toBe(50); // กลางหน้า → เต็ม cap
|
||
expect(textboxMaxWidthPct(95)).toBe(5); // ใกล้ขอบขวา → เหลือพื้นที่จริง
|
||
expect(textboxMaxWidthPct(100)).toBe(1); // ติดขอบเป๊ะ → ขั้นต่ำ 1%
|
||
});
|
||
|
||
it("TEXTBOX: กล่อง width คงที่ — กว้างตามที่กำหนดแม้ข้อความสั้น (สไตล์ Smallpdf)", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: {
|
||
position: { x: 20, y: 20 },
|
||
text: "x".repeat(10), // กว้างแค่ 12 < 30 → กล่องยังกว้าง 30
|
||
color: "#000",
|
||
fontSize: 2,
|
||
width: 30,
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.x).toBe(20);
|
||
expect(bbox.width).toBe(30);
|
||
expect(bbox.height).toBeCloseTo(3); // ยัง 1 บรรทัด
|
||
});
|
||
|
||
it("TEXTBOX: width ใหญ่กว่าพื้นที่ถึงขอบขวา → บีบเหลือพื้นที่จริง", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: {
|
||
position: { x: 80, y: 20 },
|
||
text: "x".repeat(10),
|
||
color: "#000",
|
||
fontSize: 2,
|
||
width: 40,
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.x).toBe(80);
|
||
expect(bbox.width).toBe(20); // min(40, 100−80)
|
||
});
|
||
|
||
it("TEXTBOX: width คงที่ + align ใดก็ตาม → กล่องอยู่ที่มุมซ้ายบนเสมอ", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: {
|
||
position: { x: 50, y: 20 },
|
||
text: "x".repeat(10),
|
||
color: "#000",
|
||
fontSize: 2,
|
||
align: "right",
|
||
width: 30,
|
||
},
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.x).toBe(50);
|
||
expect(bbox.width).toBe(30);
|
||
});
|
||
|
||
it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "TEXTBOX",
|
||
data: { position: { x: 10, y: 20 }, text: "", color: "#000", fontSize: 2 },
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.width).toBeCloseTo(1.2);
|
||
expect(bbox.height).toBeCloseTo(3);
|
||
});
|
||
|
||
it("STAMP: กว้าง = scale% สูง = กว้าง × STAMP_HEIGHT_RATIO", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "STAMP",
|
||
data: { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 },
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox).toEqual({ x: 10, y: 20, width: 18, height: 18 * STAMP_HEIGHT_RATIO });
|
||
});
|
||
});
|
||
|
||
describe("stampLayout / wrapStampLabel / stampBoxHeight — ตรายางหลายบรรทัด (2026-09-28)", () => {
|
||
// measure จำลองเชิงเส้น: อักษรกว้าง 0.6 เท่าของฟอนต์ — เพียงพอต่อการตรวจสูตร
|
||
const measure = (t: string, fs: number) => t.length * fs * 0.6;
|
||
const w = 100;
|
||
|
||
it("คำสั้น = บรรทัดเดียวฟอนต์เต็ม กรอบสูง 0.4w เหมือนก่อนมีการ wrap", () => {
|
||
const lay = stampLayout("สำคัญ", w, measure);
|
||
expect(lay.lines).toEqual(["สำคัญ"]);
|
||
expect(lay.fontSize).toBe(w * STAMP_FONT_RATIO);
|
||
expect(stampBoxHeight(lay, w)).toBeCloseTo(w * STAMP_HEIGHT_RATIO);
|
||
});
|
||
|
||
it("คำยาว = wrap หลายบรรทัด ฟอนต์ใหญ่กว่าการย่อบรรทัดเดียว กรอบสูงขึ้น", () => {
|
||
const label = "สำเนาถูกต้อง ใช้เอกสารฉบับนี้แทนต้นฉบับได้";
|
||
const lay = stampLayout(label, w, measure);
|
||
expect(lay.lines.length).toBeGreaterThan(1);
|
||
expect(lay.lines.length).toBeLessThanOrEqual(STAMP_MAX_LINES);
|
||
expect(lay.fontSize).toBeGreaterThan(stampFitFontSize(label, w, measure));
|
||
expect(stampBoxHeight(lay, w)).toBeGreaterThan(w * STAMP_HEIGHT_RATIO);
|
||
// ทุกบรรทัดกว้างไม่เกินขอบใน
|
||
for (const ln of lay.lines) {
|
||
expect(measure(ln, lay.fontSize)).toBeLessThanOrEqual(
|
||
w * STAMP_TEXT_WIDTH_RATIO + 1e-9,
|
||
);
|
||
}
|
||
});
|
||
|
||
it("คำยาวพอย่อได้ (30 ตัว) = ฟอนต์หยุดที่ค่าที่ให้บรรทัดเข้าเกณฑ์ STAMP_MAX_LINES", () => {
|
||
const lay = stampLayout("ก".repeat(30), w, measure);
|
||
expect(lay.lines.length).toBeLessThanOrEqual(STAMP_MAX_LINES);
|
||
expect(lay.lines.join("")).toBe("ก".repeat(30));
|
||
});
|
||
|
||
it("คำยาวเกินไป (50 ตัว) = หยุดย่อที่ฟอนต์ระดับ floor แลกกับบรรทัดเกิน (อ่านออกกว่าฟอนต์จิ๋ว)", () => {
|
||
const lay = stampLayout("ก".repeat(50), w, measure);
|
||
// STAMP_FONT_FLOOR_RATIO = 0.1 — ฟอนต์ไม่ตกต่ำกว่านี้แม้บรรทัดจะเกินเกณฑ์
|
||
expect(lay.fontSize).toBeGreaterThanOrEqual(w * 0.1 - 1e-9);
|
||
expect(lay.lines.join("")).toBe("ก".repeat(50));
|
||
});
|
||
|
||
it("wrapStampLabel: แยกตามช่องว่าง ส่วนคำเดี่ยวยาวเกินตัดที่ตัวอักษร", () => {
|
||
// อักษรกว้างเท่าฟอนต์เลย (measure ตรง) บรรทัดกว้าง 24 → "ab cd" แยกสองบรรทัด
|
||
expect(wrapStampLabel("ab cd", 10, 24, (t, fs) => t.length * fs)).toEqual([
|
||
"ab",
|
||
"cd",
|
||
]);
|
||
// "abcdef" (6×10=60 > 24) ไม่มีช่องว่าง — ตัดที่อักษร บรรทัดละ 2 ตัว = 3 บรรทัด ครบทุกตัว
|
||
const cut = wrapStampLabel("abcdef", 10, 24, (t, fs) => t.length * fs);
|
||
expect(cut).toEqual(["ab", "cd", "ef"]);
|
||
});
|
||
|
||
it("annotationBBox STAMP คำยาวสูงเกิน 0.4w (ประมาณด้วยความกว้างต่ออักษร)", () => {
|
||
const bbox = annotationBBox(
|
||
{
|
||
type: "STAMP",
|
||
data: { label: "ก".repeat(30), position: { x: 5, y: 5 }, scale: 20 },
|
||
},
|
||
S,
|
||
);
|
||
expect(bbox.height).toBeGreaterThan(20 * STAMP_HEIGHT_RATIO);
|
||
});
|
||
});
|
||
|
||
describe("translateAnnotationData", () => {
|
||
const cases: { name: string; data: AnnotationData; dx: number; dy: number }[] = [
|
||
{
|
||
name: "HIGHLIGHT เลื่อนทุก rect",
|
||
data: { text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 5, height: 5 }] },
|
||
dx: 5,
|
||
dy: 3,
|
||
},
|
||
{
|
||
name: "DRAW เลื่อนทุกจุด",
|
||
data: { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 1, y: 2 }] },
|
||
dx: 5,
|
||
dy: 3,
|
||
},
|
||
{
|
||
name: "SHAPE line เลื่อนทั้ง from/to",
|
||
data: { shape: "line", from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, strokeColor: "#000", strokeWidth: 1 },
|
||
dx: 5,
|
||
dy: 3,
|
||
},
|
||
{
|
||
name: "REDACTION เลื่อน rect",
|
||
data: { rect: { x: 10, y: 10, width: 5, height: 5 } },
|
||
dx: 5,
|
||
dy: 3,
|
||
},
|
||
{
|
||
name: "STAMP เลื่อน position",
|
||
data: { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 },
|
||
dx: 5,
|
||
dy: 3,
|
||
},
|
||
];
|
||
|
||
// ตรวจผ่าน annotationBBox (มันแยกชนิดจาก field ของ data ไม่ใช่ค่า type จึงส่ง type อะไรมาก็ได้)
|
||
// → ยืนยันว่า "ทั้งชิ้น" เลื่อนไป dx,dy พอดี ไม่ต้องเขียน assertion แยกทีละ variant
|
||
it.each(cases)("%s", ({ data, dx, dy }) => {
|
||
const out = translateAnnotationData(data, dx, dy);
|
||
const before = annotationBBox({ type: "DRAW", data }, S);
|
||
const after = annotationBBox({ type: "DRAW", data: out }, S);
|
||
expect(after.x).toBeCloseTo(before.x + dx);
|
||
expect(after.y).toBeCloseTo(before.y + dy);
|
||
});
|
||
|
||
it("COMMENT เลื่อน position", () => {
|
||
const out = translateAnnotationData({ position: { x: 10, y: 10 }, message: "m" }, 5, 3);
|
||
expect(out).toEqual({ position: { x: 15, y: 13 }, message: "m" });
|
||
});
|
||
|
||
it("DRAW: strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)", () => {
|
||
const out = translateAnnotationData(
|
||
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
|
||
10,
|
||
10,
|
||
);
|
||
expect(out).toMatchObject({ strokeWidth: 2 });
|
||
});
|
||
});
|
||
|
||
describe("scaleAnnotationData", () => {
|
||
it("HIGHLIGHT anchor {0,0} ×2: ทุกพิกัดและขนาด ×2", () => {
|
||
const out = scaleAnnotationData(
|
||
{ text: "a", color: "#000", rects: [{ x: 10, y: 20, width: 5, height: 6 }] },
|
||
{ x: 0, y: 0 },
|
||
2,
|
||
2,
|
||
);
|
||
if (!("rects" in out)) throw new Error("expected rects");
|
||
expect(out.rects[0]).toEqual({ x: 20, y: 40, width: 10, height: 12 });
|
||
});
|
||
|
||
it("HIGHLIGHT ย่อ–ขยายรอบ anchor มุมตรงข้าม: ขอบที่ anchor อยู่นิ่ง", () => {
|
||
const out = scaleAnnotationData(
|
||
{ text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 20, height: 10 }] },
|
||
{ x: 30, y: 20 },
|
||
2,
|
||
1,
|
||
);
|
||
if (!("rects" in out)) throw new Error("expected rects");
|
||
expect(out.rects[0]).toEqual({ x: -10, y: 10, width: 40, height: 10 });
|
||
// ขอบขวา: -10 + 40 = 30 = anchor.x ✓ · y และ height ไม่เปลี่ยนเพราะ sy=1
|
||
});
|
||
|
||
it("COMMENT position ขยายจาก anchor", () => {
|
||
const out = scaleAnnotationData({ position: { x: 30, y: 10 }, message: "m" }, { x: 10, y: 10 }, 2, 1);
|
||
expect(out).toEqual({ position: { x: 50, y: 10 }, message: "m" });
|
||
});
|
||
|
||
it("STAMP anchor มุมตรงข้าม ×2: position ขยายจาก anchor และ scale ×2", () => {
|
||
// bbox เดิม x 10 y 20 กว้าง 18 สูง 7.2 → anchor = มุมขวาล่าง (28, 27.2)
|
||
const out = scaleAnnotationData(
|
||
{ label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 },
|
||
{ x: 28, y: 27.2 },
|
||
2,
|
||
2,
|
||
);
|
||
expect(out).toEqual({ label: "สำคัญ", position: { x: -8, y: 12.8 }, scale: 36 });
|
||
});
|
||
|
||
it("STAMP หลังขยาย ×k รอบ anchor: bbox ใหม่ = ขอบเดิม ×k รอบ anchor (กว้าง-สูงผูกกัน)", () => {
|
||
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 };
|
||
const anchor = { x: 28, y: 27.2 };
|
||
const out = scaleAnnotationData(data, anchor, 1.5, 1.5);
|
||
const before = annotationBBox({ type: "STAMP", data }, S);
|
||
const after = annotationBBox({ type: "STAMP", data: out }, S);
|
||
expect(after.width).toBeCloseTo(before.width * 1.5);
|
||
expect(after.height).toBeCloseTo(before.height * 1.5); // สัดส่วน 0.4 คงเดิม
|
||
expect(after.x + after.width).toBeCloseTo(anchor.x); // ขอบที่ anchor อยู่นิ่ง
|
||
expect(after.y + after.height).toBeCloseTo(anchor.y);
|
||
});
|
||
|
||
it("DRAW: strokeWidth คงเดิมตามสเปก F1", () => {
|
||
const out = scaleAnnotationData(
|
||
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
|
||
{ x: 0, y: 0 },
|
||
3,
|
||
3,
|
||
);
|
||
expect(out).toMatchObject({ strokeWidth: 2 });
|
||
});
|
||
});
|
||
|
||
describe("hitTestPath", () => {
|
||
const S2 = { width: 100, height: 100 }; // % = px โดยตรง
|
||
|
||
it("ไม่มีจุดเลย → false", () => {
|
||
expect(hitTestPath([], { x: 50, y: 50 }, 5, S2)).toBe(false);
|
||
});
|
||
|
||
it("จุดเดียว: อยู่ใน threshold → true / เกิน → false", () => {
|
||
expect(hitTestPath([{ x: 50, y: 50 }], { x: 52, y: 50 }, 5, S2)).toBe(true);
|
||
expect(hitTestPath([{ x: 50, y: 50 }], { x: 60, y: 50 }, 5, S2)).toBe(false);
|
||
});
|
||
|
||
it("วัดระยะถึง segment กลางเส้น ไม่ใช่แค่ endpoint", () => {
|
||
const path = [
|
||
{ x: 10, y: 10 },
|
||
{ x: 90, y: 10 },
|
||
];
|
||
expect(hitTestPath(path, { x: 50, y: 12 }, 5, S2)).toBe(true);
|
||
expect(hitTestPath(path, { x: 50, y: 30 }, 5, S2)).toBe(false);
|
||
});
|
||
|
||
it("จุดอยู่ต่อเส้นแต่นอกช่วง segment → วัดถึง endpoint ที่ใกล้สุด", () => {
|
||
const path = [
|
||
{ x: 10, y: 10 },
|
||
{ x: 90, y: 10 },
|
||
];
|
||
expect(hitTestPath(path, { x: 0, y: 10 }, 5, S2)).toBe(false); // ห่าง endpoint 10px
|
||
expect(hitTestPath(path, { x: 7, y: 10 }, 5, S2)).toBe(true); // ห่าง 3px
|
||
});
|
||
});
|
||
|
||
describe("hitTestShape", () => {
|
||
const S2 = { width: 100, height: 100 };
|
||
|
||
it("rect: กลางพื้นที่ไม่โดน (hit เฉพาะเส้นขอบ)", () => {
|
||
const data: ShapeData = {
|
||
shape: "rect",
|
||
rect: { x: 20, y: 20, width: 60, height: 40 },
|
||
strokeColor: "#000",
|
||
strokeWidth: 1,
|
||
};
|
||
expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false);
|
||
});
|
||
|
||
it("rect: จุดบนขอบ → true / นอกกรอบห่างเกิน threshold → false", () => {
|
||
const data: ShapeData = {
|
||
shape: "rect",
|
||
rect: { x: 20, y: 20, width: 60, height: 40 },
|
||
strokeColor: "#000",
|
||
strokeWidth: 1,
|
||
};
|
||
expect(hitTestShape(data, { x: 50, y: 20 }, 3, S2)).toBe(true);
|
||
expect(hitTestShape(data, { x: 50, y: 15 }, 3, S2)).toBe(false); // ห่างขอบ 5px
|
||
});
|
||
|
||
it("ellipse: จุดบนเส้นรอบวงรี → true / กลางวง → false", () => {
|
||
const data: ShapeData = {
|
||
shape: "ellipse",
|
||
rect: { x: 20, y: 20, width: 60, height: 40 },
|
||
strokeColor: "#000",
|
||
strokeWidth: 1,
|
||
};
|
||
// center (50,40) rx=30 ry=20 → จุด (80,40) อยู่บนวงรีพอดี
|
||
expect(hitTestShape(data, { x: 80, y: 40 }, 3, S2)).toBe(true);
|
||
expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false);
|
||
});
|
||
|
||
it("line/arrow: วัดระยะถึงเส้นตรง from→to", () => {
|
||
const data: ShapeData = {
|
||
shape: "arrow",
|
||
from: { x: 10, y: 10 },
|
||
to: { x: 50, y: 50 },
|
||
strokeColor: "#000",
|
||
strokeWidth: 1,
|
||
};
|
||
expect(hitTestShape(data, { x: 31, y: 31 }, 3, S2)).toBe(true); // ใกล้เส้น ~1.4px
|
||
expect(hitTestShape(data, { x: 80, y: 80 }, 3, S2)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("annotationBBox STAMP — measure param (ขอบล่างกรอบเกินตรายาง 2026-09-28)", () => {
|
||
const S = { width: 100, height: 100 }; // % = px โดยตรง
|
||
// 10 อักษร กว้าง w=18: base=3.96, maxW=16.2 — ตามฟอนต์ของแต่ละเครื่องอาจ 1 หรือ 2 บรรทัด
|
||
const data = { label: "ตรวจสอบแล้ว", position: { x: 10, y: 20 }, scale: 18 };
|
||
const minH = 18 * STAMP_HEIGHT_RATIO; // 0.4w — สูงขั้นต่ำของคำสั้น 1 บรรทัด
|
||
|
||
it("ส่ง measure ที่ข้อความแคบพอ 1 บรรทัด → สูง = 0.4w เหมือนที่ StampItem render จริง", () => {
|
||
const narrow = (text: string, fs: number) => text.length * fs * 0.3;
|
||
const box = annotationBBox({ type: "STAMP", data }, S, narrow);
|
||
expect(box.x).toBeCloseTo(10);
|
||
expect(box.width).toBeCloseTo(18);
|
||
expect(box.height).toBeCloseTo(minH);
|
||
});
|
||
|
||
it("คำพอดี ๆ ที่ต้องย่อฟอนต์นิดเดียว = บรรทัดเดียว (ไม่ตก 2 บรรทัด) — กติกา 2026-09-28 รอบสอง", () => {
|
||
// ฟอนต์ไทยจริงเฉลี่ย ~0.45/อักษร: "ตรวจสอบแล้ว" ย่อเหลือ ~91% ของ base ก็พอบรรทัดเดียว
|
||
const real = (text: string, fs: number) => text.length * fs * 0.45;
|
||
const lay = stampLayout(data.label, 18, real);
|
||
expect(lay.lines).toEqual([data.label]);
|
||
expect(lay.fontSize).toBeLessThan(18 * STAMP_FONT_RATIO); // ย่อจริง
|
||
expect(lay.fontSize).toBeGreaterThanOrEqual(18 * 0.1); // ไม่ต่ำกว่า floor
|
||
const box = annotationBBox({ type: "STAMP", data }, S, real);
|
||
expect(box.height).toBeCloseTo(minH);
|
||
});
|
||
|
||
it("คำยาวเกิน floor (ย่อไม่ช่วย) = wrap หลายบรรทัด สูงเกิน 0.4w — ใช้ measure ที่ส่งเข้ามาจริง", () => {
|
||
const wide = (text: string, fs: number) => text.length * fs * 1.2;
|
||
const box = annotationBBox({ type: "STAMP", data }, S, wide);
|
||
expect(box.height).toBeGreaterThan(minH + 1);
|
||
// ไม่ส่ง measure (estimate ฝั่ง server) ก็เข้าเส้นทาง wrap เหมือนกันกับคำยาวระดับนี้
|
||
const longData = { ...data, label: "ก".repeat(30) };
|
||
const est = annotationBBox({ type: "STAMP", data: longData }, S);
|
||
expect(est.height).toBeGreaterThan(minH + 1);
|
||
});
|
||
});
|
||
|
||
describe("ตรายางเอียง — rotatePoint / stampRect / rotatedRectAABB (2026-09-28)", () => {
|
||
it("rotatePoint: 0° = จุดเดิม, 90° ตามเข็มรอบ origin แกน x ไปแกน y", () => {
|
||
const p = { x: 10, y: 0 };
|
||
expect(rotatePoint(p, { x: 0, y: 0 }, 0)).toEqual(p); // early return ตรง ๆ
|
||
const q = rotatePoint(p, { x: 0, y: 0 }, 90);
|
||
expect(q.x).toBeCloseTo(0); // cos(90°) มี float ตกค้าง ตรวจแบบใกล้เคียง
|
||
expect(q.y).toBeCloseTo(10);
|
||
// ย้อนหมุนกลับตำแหน่งเดิม
|
||
const back = rotatePoint(rotatePoint(p, { x: 0, y: 0 }, 37), { x: 0, y: 0 }, -37);
|
||
expect(back.x).toBeCloseTo(p.x);
|
||
expect(back.y).toBeCloseTo(p.y);
|
||
});
|
||
|
||
it("stampRect: คืนกรอบก่อนหมุน + rotation ของ data (ไม่มี field = 0)", () => {
|
||
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 };
|
||
const r = stampRect(data, S);
|
||
expect(r).toMatchObject({ x: 10, y: 20, width: 18, rotation: 0 });
|
||
const tilted = stampRect({ ...data, rotation: -30 }, S);
|
||
expect(tilted).toMatchObject({ x: 10, y: 20, width: 18, rotation: -30 });
|
||
// กรอบก่อนหมุนขนาดเท่ากันไม่ว่าจะเอียงเท่าไหร่ (หมุนทีหลังรอบ center)
|
||
expect(tilted.height).toBeCloseTo(r.height);
|
||
});
|
||
|
||
it("rotatedRectAABB: 90° สลับกว้าง-สูงรอบ center เดิม, 45° = แนวทะเลียบใหญ่สุด", () => {
|
||
const r = { x: 10, y: 20, width: 100, height: 50, rotation: 90 };
|
||
const a = rotatedRectAABB(r);
|
||
expect(a.width).toBeCloseTo(50);
|
||
expect(a.height).toBeCloseTo(100);
|
||
expect(a.x + a.width / 2).toBeCloseTo(60); // center คงเดิม
|
||
expect(a.y + a.height / 2).toBeCloseTo(45);
|
||
const b = rotatedRectAABB({ ...r, rotation: 45 });
|
||
expect(b.width).toBeCloseTo((100 + 50) / Math.SQRT2);
|
||
expect(b.height).toBeCloseTo((100 + 50) / Math.SQRT2);
|
||
});
|
||
|
||
it("annotationBBox STAMP เอียง = AABB ของกรอบหมุน (ไม่ใช่กรอบก่อนหมุน)", () => {
|
||
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 20, rotation: 90 };
|
||
const flat = annotationBBox({ type: "STAMP", data: { ...data, rotation: undefined } }, S);
|
||
const tilted = annotationBBox({ type: "STAMP", data }, S);
|
||
expect(tilted.width).toBeCloseTo(flat.height);
|
||
expect(tilted.height).toBeCloseTo(flat.width);
|
||
expect(tilted.x + tilted.width / 2).toBeCloseTo(flat.x + flat.width / 2);
|
||
});
|
||
|
||
it("translate/scale ของ STAMP เอียงยังเก็บ rotation ไปด้วย (spread ...data)", () => {
|
||
const data: AnnotationData = {
|
||
label: "สำคัญ",
|
||
position: { x: 10, y: 20 },
|
||
scale: 18,
|
||
rotation: -15,
|
||
};
|
||
expect(translateAnnotationData(data, 5, 5)).toMatchObject({ rotation: -15 });
|
||
expect(scaleAnnotationData(data, { x: 0, y: 0 }, 2, 2)).toMatchObject({
|
||
rotation: -15,
|
||
scale: 36,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("clampStampIntoPage — ตรายางเอียงชิดขอบหมุนแล้วเลื่อนกลับเข้าหน้า (2026-09-28)", () => {
|
||
const mk = (position: { x: number; y: number }, rotation: number) => ({
|
||
label: "สำคัญ",
|
||
position,
|
||
scale: 20,
|
||
rotation,
|
||
});
|
||
|
||
it("กลางหน้า (หมุนแล้วยังอยู่ใน 0–100) = position เดิมเป๊ะ", () => {
|
||
const d = mk({ x: 40, y: 40 }, 45);
|
||
expect(clampStampIntoPage(d)).toEqual(d.position);
|
||
});
|
||
|
||
it("ชิดขอบบน + หมุน 45° (AABB ล้นบน) → เลื่อนลงเข้าหน้า อีกแกนไม่ขยับ", () => {
|
||
// w=20 h=8 (คำสั้น estimate) rot 45 → AABB 19.8×19.8 รอบ center (11, 5) → y เริ่ม −4.9
|
||
const out = clampStampIntoPage(mk({ x: 1, y: 1 }, 45));
|
||
expect(out.y).toBeGreaterThan(1);
|
||
expect(out.x).toBe(1); // AABB x ≈ 1.1 ไม่ล้น → แกน x ไม่ขยับ
|
||
const aabb = rotatedRectAABB(
|
||
stampRect({ label: "สำคัญ", position: out, scale: 20, rotation: 45 }, S),
|
||
);
|
||
expect(aabb.y).toBeGreaterThanOrEqual(-1e-9);
|
||
});
|
||
|
||
it("ชิดขอบขวา + หมุน 90° → เลื่อนซ้ายกลับเข้าหน้า (แกน x ลด)", () => {
|
||
// w=20 h=8 rot 90 → AABB 8×20 รอบ center (100, 44) → x+w = 104 เกินขอบขวา 4
|
||
const d = mk({ x: 90, y: 40 }, 90);
|
||
const out = clampStampIntoPage(d);
|
||
expect(out.x).toBeLessThan(d.position.x);
|
||
const aabb = rotatedRectAABB(stampRect({ ...d, position: out }, S));
|
||
expect(aabb.x + aabb.width).toBeLessThanOrEqual(100 + 1e-9);
|
||
});
|
||
|
||
it("ไม่หมุน (0°) = พฤติกรรม clamp rect เดิม: ในหน้าอยู่แล้วไม่ขยับ", () => {
|
||
const d = mk({ x: 10, y: 10 }, 0);
|
||
expect(clampStampIntoPage(d)).toEqual(d.position);
|
||
});
|
||
});
|