eCourt-note/tests/utils/geometry.test.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 eddcec28c7 Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00

336 lines
12 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,
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: ข้อความว่าง → กว้าง 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("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);
});
});