299 lines
16 KiB
TypeScript
299 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import type {
|
||
AnnotationDTO,
|
||
AnnotationData,
|
||
AnnotationType,
|
||
CommentData,
|
||
DrawData,
|
||
HighlightData,
|
||
StampData,
|
||
TextboxData,
|
||
} from "@/db/schema";
|
||
import {
|
||
DRAW_PENS,
|
||
STAMP_FONT_RATIO,
|
||
STAMP_PRESETS,
|
||
STAMP_TEXT_WIDTH_RATIO,
|
||
annotationNote,
|
||
annotationPreviewText,
|
||
annotationTypeLabel,
|
||
describeAnnotationChange,
|
||
effectiveDrawOpacity,
|
||
sanitizePdfText,
|
||
stampFitFontSize,
|
||
} from "@/utils/annotation-display";
|
||
|
||
const dto = (type: AnnotationType, data: AnnotationData): AnnotationDTO => ({
|
||
id: "00000000-0000-0000-0000-000000000001",
|
||
fileId: "00000000-0000-0000-0000-000000000002",
|
||
pageIndex: 0,
|
||
userId: "user-1",
|
||
userName: "ทดสอบ",
|
||
type,
|
||
data,
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||
resolved: false,
|
||
resolvedAt: null,
|
||
});
|
||
|
||
describe("annotationTypeLabel", () => {
|
||
it("HIGHLIGHT ตาม style + fallback เมื่อไม่ระบุ style", () => {
|
||
expect(
|
||
annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [], style: "underline" })),
|
||
).toBe("เส้นใต้");
|
||
expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์ข้อความ");
|
||
});
|
||
|
||
it("ครบทุกชนิดที่เหลือ", () => {
|
||
expect(annotationTypeLabel(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [] }))).toBe("เส้นวาด");
|
||
expect(annotationTypeLabel(dto("COMMENT", { position: { x: 0, y: 0 }, message: "m" }))).toBe("คอมเมนต์");
|
||
expect(
|
||
annotationTypeLabel(
|
||
dto("SHAPE", { shape: "ellipse", rect: { x: 0, y: 0, width: 1, height: 1 }, strokeColor: "#000", strokeWidth: 1 }),
|
||
),
|
||
).toBe("รูปทรง (วงรี)");
|
||
expect(annotationTypeLabel(dto("REDACTION", { rect: { x: 0, y: 0, width: 1, height: 1 } }))).toBe("ปิดพราง");
|
||
expect(
|
||
annotationTypeLabel(dto("TEXTBOX", { position: { x: 0, y: 0 }, text: "t", color: "#000", fontSize: 1 })),
|
||
).toBe("กล่องข้อความ");
|
||
expect(annotationTypeLabel(dto("STAMP", { label: "สำคัญ", position: { x: 0, y: 0 }, scale: 18 }))).toBe("ตรายาง");
|
||
});
|
||
});
|
||
|
||
describe("annotationPreviewText", () => {
|
||
it("HIGHLIGHT ใช้ข้อความที่คลุม / ว่าง → placeholder", () => {
|
||
expect(
|
||
annotationPreviewText(dto("HIGHLIGHT", { text: "ข้อความที่คลุม", color: "#000", rects: [] })),
|
||
).toBe("ข้อความที่คลุม");
|
||
expect(annotationPreviewText(dto("HIGHLIGHT", { text: "", color: "#000", rects: [] }))).toBe(
|
||
"(ไม่มีข้อความ)",
|
||
);
|
||
});
|
||
|
||
it("COMMENT → message, TEXTBOX → text, STAMP → label, DRAW → ข้อความตายตัว", () => {
|
||
expect(
|
||
annotationPreviewText(dto("COMMENT", { position: { x: 0, y: 0 }, message: "ดูจุดนี้" })),
|
||
).toBe("ดูจุดนี้");
|
||
expect(
|
||
annotationPreviewText(dto("TEXTBOX", { position: { x: 0, y: 0 }, text: "หมายเหตุ", color: "#000", fontSize: 1 })),
|
||
).toBe("หมายเหตุ");
|
||
expect(
|
||
annotationPreviewText(dto("STAMP", { label: "ตรวจสอบแล้ว", position: { x: 0, y: 0 }, scale: 18 })),
|
||
).toBe("ตรวจสอบแล้ว");
|
||
expect(
|
||
annotationPreviewText(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 0, y: 0 }] })),
|
||
).toBe("เส้นวาดอิสระ");
|
||
});
|
||
});
|
||
|
||
describe("annotationNote", () => {
|
||
it("มี note → คืนค่านั้น / ไม่มี → ช่องว่าง", () => {
|
||
expect(
|
||
annotationNote(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [], note: "เหตุผล" })),
|
||
).toBe("เหตุผล");
|
||
expect(annotationNote(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [] }))).toBe("");
|
||
});
|
||
|
||
it("COMMENT (ไม่มี field note) → ช่องว่าง", () => {
|
||
expect(annotationNote(dto("COMMENT", { position: { x: 0, y: 0 }, message: "m" }))).toBe("");
|
||
});
|
||
});
|
||
|
||
describe("effectiveDrawOpacity", () => {
|
||
it("opacity ที่เก็บชัดชนะเสมอ", () => {
|
||
expect(effectiveDrawOpacity({ pen: "highlighter", opacity: 0.8 })).toBe(0.8);
|
||
expect(effectiveDrawOpacity({ opacity: 0.3 })).toBe(0.3);
|
||
});
|
||
|
||
it("ไม่มี opacity → fallback ตาม preset ของ pen", () => {
|
||
expect(effectiveDrawOpacity({ pen: "highlighter" })).toBe(DRAW_PENS.highlighter.opacity);
|
||
expect(effectiveDrawOpacity({ pen: "pencil" })).toBe(DRAW_PENS.pencil.opacity);
|
||
});
|
||
|
||
it("ไม่มีทั้ง pen และ opacity → pencil", () => {
|
||
expect(effectiveDrawOpacity({})).toBe(DRAW_PENS.pencil.opacity);
|
||
});
|
||
});
|
||
|
||
describe("presets ที่ sidebar/export ใช้ร่วมกัน", () => {
|
||
it("STAMP_PRESETS มีค่าตาม D7 (preset ข้อความเท่านั้น)", () => {
|
||
expect(STAMP_PRESETS).toEqual(["ตรวจสอบแล้ว", "สำคัญ"]);
|
||
});
|
||
});
|
||
|
||
describe("stampFitFontSize — ย่อฟอนต์ตรายางให้ label บรรทัดเดียว", () => {
|
||
// measure จำลอง: อักษรฐาน ๆ กว้าง 0.55em (ไม่มี document บนจอ = คืน 0 ก็ผ่านเงื่อนไขพอดี)
|
||
const measure = (text: string, fontSize: number) =>
|
||
Array.from(text).filter((ch) => !/[ัิ-ฺ็-๎]/.test(ch)).length *
|
||
0.55 *
|
||
fontSize;
|
||
|
||
it("ข้อความสั้น (สำคัญ) พอดี → คงขนาด base = STAMP_FONT_RATIO × กรอบ", () => {
|
||
const w = 144;
|
||
expect(stampFitFontSize("สำคัญ", w, measure)).toBe(w * STAMP_FONT_RATIO);
|
||
});
|
||
|
||
it("ข้อความยาว (ตรวจสอบแล้ว) ล้นกรอบ → ย่อตามสัดส่วนที่ล้น จนกว้าง = STAMP_TEXT_WIDTH_RATIO × กรอบ", () => {
|
||
const w = 144;
|
||
const size = stampFitFontSize("ตรวจสอบแล้ว", w, measure);
|
||
expect(size).toBeLessThan(w * STAMP_FONT_RATIO);
|
||
expect(measure("ตรวจสอบแล้ว", size)).toBeCloseTo(w * STAMP_TEXT_WIDTH_RATIO);
|
||
});
|
||
|
||
it("ข้อความว่าง/วัดไม่ได้ (คืน 0) → ใช้ขนาด base", () => {
|
||
expect(stampFitFontSize("", 100, measure)).toBe(100 * STAMP_FONT_RATIO);
|
||
expect(stampFitFontSize("ตรวจสอบแล้ว", 100, () => 0)).toBe(100 * STAMP_FONT_RATIO);
|
||
});
|
||
});
|
||
|
||
|
||
describe("describeAnnotationChange — สรุปสิ่งที่เปลี่ยนของ version history (F19)", () => {
|
||
it("HIGHLIGHT เปลี่ยนสี/ความทึบ → ชื่อสีไทยจาก palette + % ตามหน่วย UI", () => {
|
||
const prev: HighlightData = { text: "ข้อ 3", color: "#ef4444", rects: [], opacity: 0.5 };
|
||
const next: HighlightData = { text: "ข้อ 3", color: "#3b82f6", rects: [], opacity: 1 };
|
||
expect(describeAnnotationChange("HIGHLIGHT", prev, next)).toEqual([
|
||
"สี: แดง → น้ำเงิน",
|
||
"ความทึบ: 50% → 100%",
|
||
]);
|
||
});
|
||
|
||
it("สีนอก palette (ปุ่มสีรุ้ง) → แสดง hex ตรง ๆ", () => {
|
||
const prev: HighlightData = { text: "a", color: "#123456", rects: [] };
|
||
const next: HighlightData = { text: "a", color: "#abcdef", rects: [] };
|
||
expect(describeAnnotationChange("HIGHLIGHT", prev, next)).toEqual(["สี: #123456 → #abcdef"]);
|
||
});
|
||
|
||
it("COMMENT แก้ข้อความ + ย้าย pin", () => {
|
||
const prev: CommentData = { position: { x: 10, y: 20 }, message: "ดูจุดนี้" };
|
||
const next: CommentData = { position: { x: 40, y: 20 }, message: "ดูจุดนี้ (แก้)" };
|
||
expect(describeAnnotationChange("COMMENT", prev, next)).toEqual([
|
||
'แก้ข้อความ: "ดูจุดนี้" → "ดูจุดนี้ (แก้)"',
|
||
"ย้ายตำแหน่ง",
|
||
]);
|
||
});
|
||
|
||
it("DRAW ย้ายทั้งเส้น → ย้ายตำแหน่ง / eraser ตัดจุดโดย bbox เดิม → แก้เส้น", () => {
|
||
const prev: DrawData = {
|
||
strokeColor: "#ef4444",
|
||
strokeWidth: 0.4,
|
||
paths: [
|
||
{ x: 10, y: 10 },
|
||
{ x: 20, y: 10 },
|
||
{ x: 30, y: 10 },
|
||
{ x: 40, y: 10 },
|
||
],
|
||
};
|
||
const moved: DrawData = { ...prev, paths: prev.paths.map((p) => ({ ...p, y: p.y + 5 })) };
|
||
expect(describeAnnotationChange("DRAW", prev, moved)).toEqual(["ย้ายตำแหน่ง"]);
|
||
|
||
const erased: DrawData = {
|
||
...prev,
|
||
paths: [prev.paths[0], prev.paths[1], prev.paths[3]], // ตัดจุดกลางทิ้ง bbox คงเดิม
|
||
};
|
||
expect(describeAnnotationChange("DRAW", prev, erased)).toEqual(["แก้เส้น"]);
|
||
});
|
||
|
||
it("DRAW/SHAPE เปลี่ยนความหนา → แสดง ×10 ตามหน่วยของตัวปรับใน UI", () => {
|
||
const prev: DrawData = { strokeColor: "#ef4444", strokeWidth: 0.4, paths: [{ x: 1, y: 1 }] };
|
||
const next: DrawData = { ...prev, strokeWidth: 0.8 };
|
||
expect(describeAnnotationChange("DRAW", prev, next)).toEqual(["ความหนา: 4 → 8"]);
|
||
});
|
||
|
||
it("โน้ต N1: เพิ่ม / ลบ / แก้", () => {
|
||
const base: DrawData = { strokeColor: "#ef4444", strokeWidth: 0.4, paths: [] };
|
||
expect(describeAnnotationChange("DRAW", base, { ...base, note: "เหตุผล" })).toEqual([
|
||
"เพิ่มโน้ต: เหตุผล",
|
||
]);
|
||
expect(describeAnnotationChange("DRAW", { ...base, note: "เหตุผล" }, base)).toEqual(["ลบโน้ต"]);
|
||
expect(
|
||
describeAnnotationChange("DRAW", { ...base, note: "เหตุผล" }, { ...base, note: "เหตุผลใหม่" }),
|
||
).toEqual(["แก้โน้ต: เหตุผล → เหตุผลใหม่"]);
|
||
});
|
||
|
||
it("ไม่มีอะไรเปลี่ยน → ว่าง", () => {
|
||
const same: CommentData = { position: { x: 5, y: 5 }, message: "m" };
|
||
expect(describeAnnotationChange("COMMENT", same, { ...same })).toEqual([]);
|
||
});
|
||
|
||
it("TEXTBOX แก้ข้อความแล้วกล่อง wrap ใหม่ → ไม่แจ้ง ปรับขนาด ที่เป็นผลพลอง", () => {
|
||
const prev: TextboxData = {
|
||
position: { x: 10, y: 10 },
|
||
text: "สั้น",
|
||
color: "#111827",
|
||
fontSize: 2.5,
|
||
};
|
||
const next: TextboxData = { ...prev, text: "ข้อความที่ยาวขึ้นมาก" };
|
||
const changes = describeAnnotationChange("TEXTBOX", prev, next);
|
||
expect(changes).toContain('แก้ข้อความ: "สั้น" → "ข้อความที่ยาวขึ้นมาก"');
|
||
expect(changes).not.toContain("ปรับขนาด");
|
||
});
|
||
|
||
it("STAMP เปลี่ยนสีไม่ระบุ (undefined) ต้องเทียบกับ default STAMP_COLOR ก่อน", () => {
|
||
const prev: StampData = { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 };
|
||
const next: StampData = { ...prev, color: "#b91c1c" }; // = default ไม่ถือว่าเปลี่ยน
|
||
expect(describeAnnotationChange("STAMP", prev, next)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("sanitizePdfText — ล้างข้อความเพี้ยนจาก textLayer ของ PDF ไทย (glyph ต่อ glyph)", () => {
|
||
// control char ขยะที่พบจริงใน textLayer ของคำสั่งศาลปกครอง เช่น 04/05/0e/0f/03/12/1b
|
||
const ctrl = (n: number) => String.fromCharCode(n);
|
||
|
||
it("กลุ่ม control ขยะที่มีช่องว่างล้อมสองข้าง = ตัดทั้งกลุ่ม (เกิดในคำเดียวกัน)", () => {
|
||
// "คำ"+"สั่"+" "+04+" "+05+" "+"งศาลปกครอง" จาก textLayer จริง
|
||
expect(sanitizePdfText(`คำสั่ ${ctrl(4)} ${ctrl(5)} งศาลปกครอง`)).toBe("คำสั่งศาลปกครอง");
|
||
expect(sanitizePdfText(`สั่ ${ctrl(14)} งสั่ ${ctrl(15)} ด`)).toBe("สั่งสั่ด");
|
||
});
|
||
|
||
it("control ขยะที่ช่องว่างติดข้างเดียว = เก็บช่องว่างไว้ (อาจเป็นช่องว่างจริง)", () => {
|
||
// "ที่"+12+05+" "+"๑๑" — ช่องว่างก่อน ๑๑ เป็นช่องว่างจริงของเอกสาร
|
||
expect(sanitizePdfText(`ที่${ctrl(18)}${ctrl(5)} ๑๑`)).toBe("ที่ ๑๑");
|
||
// control เปล่า ๆ ไม่มีช่องว่างแนบ = ตัดทิ้งอย่างเดียว
|
||
expect(sanitizePdfText(`คำสั่${ctrl(4)}${ctrl(5)}ง`)).toBe("คำสั่ง");
|
||
});
|
||
|
||
it("ยุบช่องว่างซ้ำและเก็บตำแหน่งขึ้นบรรทัดใหม่ (\\n ไม่โดนตัด)", () => {
|
||
expect(sanitizePdfText("กับลิ้ง ปุจฉา")).toBe("กับลิ้งปุจฉา");
|
||
expect(sanitizePdfText("บรรทัดหนึ่ง \n บรรทัดสอง")).toBe("บรรทัดหนึ่ง\nบรรทัดสอง");
|
||
});
|
||
|
||
it("heading จริงของคำสั่งศาลปกครอง (space ขยะเป็นชุด + ctrl กลางคำ) → ตัดหมด", () => {
|
||
// "คำ"+" "×3+"สั่"+04+05+"งศาลปกครอง"+" "×2+"สั่"+0e+"ง"+" "×2+"สั่"+0f+"ด"
|
||
const heading =
|
||
`คำ สั่${ctrl(4)}${ctrl(5)}งศาลปกครอง สั่${ctrl(14)}ง สั่${ctrl(15)}ด`;
|
||
expect(sanitizePdfText(heading)).toBe("คำสั่งศาลปกครองสั่งสั่ด");
|
||
});
|
||
|
||
it("ข้อความปกติไม่มีขยะ = คงเดิม", () => {
|
||
expect(sanitizePdfText("คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖")).toBe(
|
||
"คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("describeAnnotationChange — STAMP มุมเอียง (2026-09-28)", () => {
|
||
const base = { label: "สำคัญ", position: { x: 30, y: 20 }, scale: 18 };
|
||
|
||
it("มุมเอียงเปลี่ยนอย่างเดียว → บรรทัด 'มุมเอียง' และไม่ติด geometry ผลพลองของ AABB ที่หมุน", () => {
|
||
const changes = describeAnnotationChange("STAMP", base, { ...base, rotation: -15 });
|
||
expect(changes).toContain("มุมเอียง: 0° → -15°");
|
||
expect(changes.some((c) => c.includes("ย้ายตำแหน่ง") || c.includes("ปรับขนาด"))).toBe(false);
|
||
});
|
||
|
||
it("มุมเอียงเปลี่ยนทั้งต้นทางปลายทาง", () => {
|
||
expect(describeAnnotationChange("STAMP", { ...base, rotation: 45 }, { ...base, rotation: 0 })).toEqual([
|
||
"มุมเอียง: 45° → 0°",
|
||
]);
|
||
});
|
||
|
||
it("ข้อความเปลี่ยน → ไม่ติด geometry ผลพลองของการจัดบรรทัด (แบบ TEXTBOX)", () => {
|
||
const changes = describeAnnotationChange(
|
||
"STAMP",
|
||
{ ...base, rotation: 30 },
|
||
{ label: "สำเนาถูกต้อง ใช้เอกสารฉบับนี้แทนต้นฉบับได้", position: { x: 30, y: 20 }, scale: 18, rotation: 30 },
|
||
);
|
||
expect(changes.length).toBe(1);
|
||
expect(changes[0]).toContain("ข้อความตรายาง");
|
||
});
|
||
});
|