eCourt-note/tests/utils/annotation-display.test.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 6fd3a1515f ซ่อม 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

175 lines
9.6 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 } from "@/db/schema";
import {
DRAW_PENS,
STAMP_FONT_RATIO,
STAMP_PRESETS,
STAMP_TEXT_WIDTH_RATIO,
annotationNote,
annotationPreviewText,
annotationTypeLabel,
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("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(
"คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖",
);
});
});