eCourt-note/tests/utils/annotation-display.test.ts
Your Name e0c78d68fe
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 43s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
bookmark: ดักความยาวชื่อ/โน้ตที่ช่องกรอก + F19 สรุปสิ่งที่เปลี่ยนใน dialog ประวัติ + formatThaiDate — (1) bookmark limits: utils/bookmark-limits.ts ใหม่เป็น source of truth เดียว (ชื่อ 120/โน้ต 200 ตัวอักษร) server action import ร่วมแทน const ท้องถิ่น + ใส่ maxLength ที่ popup บันทึก bookmark (PDFViewer) และฟอร์มแก้ไข ✏ ใน sidebar ดักทั้งพิมพ์/paste ตั้งแต่ในช่อง ไม่รอ error ตอนกดบันทึก + แก้ off-by-one ของ normalizeBookmarkTitle (เดิม slice 120 แล้วต่อ … = 121 ตัว ทำให้คลุมข้อความยาวแล้วกด B บันทึกเลยโดน limit ของ server ตีกลับทั้งที่ไม่ได้พิมพ์เอง — ตอนนี้ slice 119 + … = 120 พอดีตามสเปก §7 N2 ที่เขียนไว้ ≤ 120 อยู่แล้ว) + PLAN-FEAT.md §7 N2 บันทึกเพิ่ม — (2) งานคู่ขนาดจากอีก session (แชร์ไฟล์ PDFViewer/sidebar เลยรวม commit): dialog ประวัติ F19 แสดงสรุปสิ่งที่เปลี่ยนต่อรอบผ่าน describeAnnotationChange ใหม่ใน utils/annotation-display.ts (เทียบ snapshot ก่อนแก้ v.data กับ state ถัดไป — snapshot รอบใหม่กว่า หรือค่าปัจจุบันจาก realtime lookup สด historyCurrent — สรุปเป็นหน่วยเดียวกับตัวปรับ UI: ความหนา ×10 ความทึบ % fontSize px ชื่อสี preset ย้าย/ปรับขนาดจาก bbox ผลต่างเกิน GEOM_EPS_PCT 0.5% ข้อความ/โน้ต/label) + dialog ขยาย w-96 max-h-[60vh] แถวเปลี่ยน items-start + utils/date-format.ts ใหม่ formatThaiDate วันที่ไทยเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา) แทน toLocaleString("th-TH") ทุกจุดแสดงวันที่ใน UI (หน้ารายการไฟล์ sidebar ประวัติ/รายการ timestamps ใน PDFViewer) กัน server/client locale ไม่ตรงกัน + tests ใหม่/ขยาย (date-format + describeAnnotationChange) — CLAUDE.md sync โครงสร้าง utils — verify: tsc strict ผ่าน + vitest 139/139 ผ่าน
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 17:02:31 +07:00

273 lines
15 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 {
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(
"คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖",
);
});
});