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

455 lines
16 KiB
TypeScript
Raw Normal View History

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" });
});
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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);
});
});