eCourt-note/tests/utils/geometry.test.ts

454 lines
16 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.

import { describe, expect, it } from "vitest";
import type { AnnotationData, ShapeData } from "@/db/schema";
import {
STAMP_HEIGHT_RATIO,
annotationBBox,
hitTestPath,
hitTestShape,
scaleAnnotationData,
textboxMaxWidthPct,
translateAnnotationData,
} 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("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);
});
});