eCourt-note/tests/utils/coordinates.test.ts
Your Name b123ecc9b2
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
fix(pdf-selection): match text to dragged lines
ชื่อ bookmark/ข้อความ copy จากการคลุมข้อความเพี้ยน 3 รูปแบบบน
เอกสารไทย (คำขอผู้ใช้ 2026-10-05 รายงานบนฟอร์มราชการ
"แบบบันทึกผลการทดลองปฏิบัติหน้าที่ราชการ" ที่บรรทัดคุมชิด ~16px)

(1) สองบรรทัดสอดแทรกตัวต่อตัว ("แบบบันทึกผ(สำลกหารัรบท…") —
    บรรทัดไทยคุมชิด rect สูง ~16px เท่าระยะห่างบรรทัด ตัวอักษร
    ของบรรทัดถัดไป (สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อน
    หน้าจริง 1-3px: hit-test เดิม (findIndex ตัวแรกที่เจอ + eps)
    จับข้ามบรรทัดจนโดนเรียงรวมกันด้วย x — แก้ selectionTextWithin
    เป็นประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรต้องอยู่ในแถบ
    ของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับมากสุด

(2) ลากบรรทัดเดียวแต่ได้หลายบรรทัด — DOM range ของ pdf.js มักครอบ
    span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret
    ปลายทางหลุดเข้า span ถัดไป: range.toString() ได้ 3 บรรทัดทั้งที่
    ลากบรรทัดเดียว) — ประตูจุดกึ่งกลางใน (1) ตัดตัวอักษรนอกแถบ
    ทิ้งเอง ได้เฉพาะข้อความที่เห็นบนจอจริง (ดีกว่า native)

(3) ✓ checkbox คอลัมน์ขวาติดมากับบล็อกที่เลือก — pdf.js วาง span ✓
    ไว้ระหว่างบรรทัดข้อความพอดีจน native selection ครอบมันตาม DOM
    (raw toString มี ✓ เหมือนกัน) — rectsInSweptPath: บรรทัดกลาง
    เดิมเก็บทั้งบรรทัดแบบไม่ดูแกน x เปลี่ยนเป็นเก็บเฉพาะช่วงคอลัมน์
    เดียวกับข้อความที่เก็บได้จริง: ยึดขอบซ้าย/ขวาจริงของ rect ที่เก็บ
    ได้จากบรรทัดแรก/สุดท้าย + ขยายริมละ 2×lineTol (ทน justify gap
    ระหว่าง rect, ตาม zoom อัตโนมัติ) — span ที่ห่างเกิน (✓ gap
    30px+) ถูกตัดทั้งจากข้อความและกรอบมาร์กเหลือง (rect ชุดเดียวกัน)

(4) แยกบรรทัด/เรียงลำดับกลุ่ม rect ใช้เกณฑ์เดียวกัน — เดิม sameLine
    (แทรก \n) ใช้ |midY| ≤ max(height) ซึ่งตัดสินผิดกับบรรทัดคุมชิด
    (16 ≤ 16 = บรรทัดเดียวกัน) และ comparator เรียง rectOrder ก็ใช้
    heuristic เดิมจนย่อหน้าที่บรรทัดแรกเยื้องเข้า (left มากกว่า
    บรรทัดถัดไป) โดนเรียงตาม left กลายเป็นข้อความสลับลำดับบรรทัด —
    ทั้งคู่เปลี่ยนมาใช้ "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด"

(5) แก้ตาม code review 2026-10-05 ข้อ 1: หน้าต่างคอลัมน์ของบรรทัด
    กลางห้ามยึดพิกัดจุดลากตรง ๆ (ลากชัน/ลาก margin ซ้ายแล้ว rect
    ครึ่งขวาของบรรทัดกลางโดนตัด) — ยึดขอบจริงตาม (3) แทน; doc comment
    ของ rectsInSweptPath ที่ยังบอก "ไม่ตัดแกน x" sync ให้ตรงพฤติกรรม

(6) tests: coordinates +3 เคส (✓ คนละคอลัมน์ถูกตัด + rect คอลัมน์
    เดียวกันยังเก็บ / ลากชันผ่าน justify gap ยังเก็บเต็มบรรทัด /
    ลาก margin ซ้ายบรรทัดกลางไม่หาย) — รวม 215/215 ผ่าน + tsc

(7) docs sync: rules/pdf-viewer.md + rules/coordinates.md

ยืนยันใน browser จริง (Playwright) บนไฟล์ฟอร์มราชการของผู้ใช้:
heading บรรทัดเดียว / 2 บรรทัด / ลากเลยขอบขวา / บล็อก ๑+๒ ปกติ
และลากชัน — ชื่อ bookmark ตรงเป๊ะ ไม่มีข้อความปน/สลับ/✓ ติดมา

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-05 11:31:33 +07:00

298 lines
15 KiB
TypeScript
Raw Permalink 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 {
clamp,
getPointerPercent,
mergeRectsByLine,
pathToPercent,
pathToPx,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
rectsInSweptPath,
toPercent,
toPx,
type Size,
} from "@/utils/coordinates";
const size: Size = { width: 800, height: 600 };
describe("toPercent / toPx", () => {
it("px → % ตามสัดส่วนขนาดที่ส่งเข้ามา", () => {
expect(toPercent(400, 800)).toBe(50);
expect(toPercent(300, 600)).toBe(50);
expect(toPercent(0, 800)).toBe(0);
});
it("roundtrip px → % → px ได้ค่าเดิม", () => {
expect(toPx(toPercent(123, 800), 800)).toBeCloseTo(123);
});
});
describe("clamp", () => {
it("ต่ำกว่า min → min, อยู่ในช่วง → ค่าเดิม, เกิน max → max", () => {
expect(clamp(-5, 0, 100)).toBe(0);
expect(clamp(42, 0, 100)).toBe(42);
expect(clamp(120, 0, 100)).toBe(100);
});
});
describe("point/rect/path converters", () => {
it("point: x อิง width, y อิง height (แกนแยกกัน)", () => {
expect(pointToPercent({ x: 400, y: 300 }, size)).toEqual({ x: 50, y: 50 });
expect(pointToPx({ x: 25, y: 75 }, size)).toEqual({ x: 200, y: 450 });
});
it("rect: แปลงครบทั้ง 4 ค่า", () => {
expect(rectToPercent({ x: 80, y: 60, width: 160, height: 120 }, size)).toEqual({
x: 10,
y: 10,
width: 20,
height: 20,
});
expect(rectToPx({ x: 10, y: 10, width: 20, height: 20 }, size)).toEqual({
x: 80,
y: 60,
width: 160,
height: 120,
});
});
it("path: แปลงทุกจุดในลิสต์", () => {
expect(
pathToPercent(
[
{ x: 0, y: 0 },
{ x: 800, y: 600 },
],
size,
),
).toEqual([
{ x: 0, y: 0 },
{ x: 100, y: 100 },
]);
expect(pathToPx([{ x: 50, y: 50 }], size)).toEqual([{ x: 400, y: 300 }]);
});
});
describe("getPointerPercent", () => {
const makeEl = (left: number, top: number, width: number, height: number) =>
({ getBoundingClientRect: () => ({ left, top, width, height }) }) as HTMLElement;
it("คิดจากขอบ element ไม่ใช่ขอบจอ", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 200, clientY: 100 }, el)).toEqual({ x: 50, y: 50 });
});
it("เกินขอบ element → clamp 100 / ต่ำกว่าขอบ → clamp 0", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 999, clientY: 999 }, el)).toEqual({ x: 100, y: 100 });
expect(getPointerPercent({ clientX: 0, clientY: 0 }, el)).toEqual({ x: 0, y: 0 });
});
});
describe("mergeRectsByLine", () => {
it("rects บรรทัดเดียวกัน (y overlap) → รวมเป็น 1 rect ครอบกว้าง/สูงสุด", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 45, y: 21, width: 20, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toEqual({ x: 10, y: 20, width: 55, height: 6 });
});
it("rects คนละบรรทัด (y ไม่ overlap) → คงแยกกัน", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 12, y: 30, width: 30, height: 5 },
]);
expect(out).toHaveLength(2);
});
it("บรรทัดติดกันที่กล่องซ้อนกันเล็กน้อย (leading แน่น/ฟอนต์ไทย) → ยังแยกบรรทัดกัน", () => {
// ซ้อน 1 หน่วยจากความสูง 5 (20%) — ไม่ใช่บรรทัดเดียวกัน
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 12, y: 24, width: 30, height: 5 },
]);
expect(out).toHaveLength(2);
});
it("ลำดับ input ไม่สำคัญ — rect ซ้ายมาทีหลังก็รวมถูกจุดเริ่ม", () => {
const out = mergeRectsByLine([
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ x: 10, width: 55 });
});
it("ช่องว่างระหว่างคำในบรรทัดเดียวกันถูกครอบด้วย (union แกน x)", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 10, height: 5 },
{ x: 80, y: 20, width: 10, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0].width).toBe(80); // 10 → 90
});
it("array ว่าง → ว่าง / rect เดี่ยว → ค่าเดิม", () => {
expect(mergeRectsByLine([])).toEqual([]);
expect(mergeRectsByLine([{ x: 1, y: 2, width: 3, height: 4 }])).toEqual([
{ x: 1, y: 2, width: 3, height: 4 },
]);
});
it("ไม่แก้ array และ rect ต้นฉบับ", () => {
const input = [
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
];
const snapshot = input.map((r) => ({ ...r }));
mergeRectsByLine(input);
expect(input).toEqual(snapshot);
});
});
describe("rectsInSweptPath", () => {
// DOMRect ใน node env ไม่มี — function อ่านแค่ x/y/width/height จึงใช้ plain object แทน
const R = (x: number, y: number, width: number, height: number) =>
({ x, y, width, height }) as DOMRect;
// บรรทัดสูง 12px ที่ y=100/120/140 (lineTol = 12×0.6 = 7.2)
const L1 = [R(50, 100, 100, 12), R(200, 100, 100, 12)];
const L2 = [R(20, 120, 280, 12)];
const L3 = [R(50, 140, 100, 12), R(200, 140, 100, 12)];
it("ลากในบรรทัดเดียว เก็บเฉพาะ rect ที่เส้นทางพาดผ่าน (ตัดซ้าย/ขวาที่ไม่ถึง)", () => {
const kept = rectsInSweptPath(L1, { x: 60, y: 106 }, { x: 150, y: 106 });
expect(kept).toEqual([L1[0]]); // rect 50–150 พาด 60–150 ✓, rect 200–300 ไม่ถึง
});
it("ลากขึ้นหลายบรรทัด: บรรทัดแรก(บน)เก็บจากจุดจบ→ท้ายบรรทัด, บรรทัดล่างเก็บต้นบรรทัด→จุดเริ่ม, บรรทัดกลางทั้งบรรทัด", () => {
const kept = rectsInSweptPath(
[...L1, ...L2, ...L3],
{ x: 100, y: 146 }, // เริ่มกลางบรรทัดล่าง (y=140)
{ x: 150, y: 106 }, // จบกลางบรรทัดบน (y=100)
);
expect(kept).toEqual([L1[1], ...L2, L3[0]]); // บนเก็บ 200–300 (จาก 150→ท้าย), ล่างเก็บ 50–150 (ต้น→100)
});
it("บรรทัดนอกช่วงแนวตั้งของเส้นทางถูกทิ้ง (กันมาร์กเด้งไปบรรทัดอื่น)", () => {
const far = [R(50, 60, 100, 12)]; // เหนือบรรทัดบนเกิน lineTol
const kept = rectsInSweptPath([...far, ...L2], { x: 100, y: 126 }, { x: 100, y: 146 });
expect(kept).toEqual(L2);
});
it("ไม่ขยับ (≤4px — ดับเบิลคลิกเลือกคำ): เก็บทุก rect ของบรรทัดนั้นโดยไม่ตัดแกน x", () => {
// จุดคลิกอยู่บนบรรทัด L1 (cy=106) ขยับนิดเดียว — rect ทั้งคู่ของบรรทัดเก็บครบ
// แม้ x ไม่พาดผ่านจุดคลิก (ไม่มีแนวลากให้อิง) ส่วนบรรทัดอื่นนอกแนวตั้งถูกทิ้ง
const kept = rectsInSweptPath([...L1, ...L3], { x: 100, y: 106 }, { x: 102, y: 107 });
expect(kept).toEqual([...L1]);
});
it("✓ checkbox คอลัมน์ขวาที่ลอยระหว่างบรรทัดข้อความ (บรรทัดกลาง) ไม่ติดมา (2026-10-05)", () => {
// ฟอร์มราชการ: span ✓ วางระหว่างบรรทัดข้อความพอดี DOM ของ native selection ครอบมัน
// ติดมาด้วย — แต่ rect ลอยคนละคอลัมน์กับช่วง x ที่ลาก ต้องถูกตัดทิ้ง
const A = R(50, 100, 100, 12); // บรรทัดบน cy=106
const C = R(50, 140, 100, 12); // บรรทัดล่าง cy=146
const check = R(900, 110, 12, 12); // cy=116 = บรรทัดกลางของ corridor
const kept = rectsInSweptPath(
[A, check, C],
{ x: 60, y: 106 },
{ x: 140, y: 146 },
);
expect(kept).toEqual([A, C]); // ไม่มี check
// ฝั่งตรงข้าม: rect กลางที่ซ้อนช่วง x ที่ลาก (คอลัมน์เดียวกัน) ยังเก็บครบ
const mid = R(80, 120, 120, 12);
expect(rectsInSweptPath([A, mid, C], { x: 60, y: 106 }, { x: 140, y: 146 })).toEqual([
A,
mid,
C,
]);
});
it("ลากชัน ๆ ผ่านบรรทัดกลางที่ justify จนแตกหลาย rect: ยังเก็บครบทั้งบรรทัด (code review 2026-10-05)", () => {
// บรรทัดแรกเยื้องสั้น [100-300], บรรทัดกลางยาวแตก 2 ก้อน [50-300]+[310-750]
// (ช่องว่าง 10px จาก justify), บรรทัดล่าง [50-750] — ลากจาก (120,106) ลง (150,146)
// เกือบตั้ง: ต้องได้บรรทัดกลางทั้งสองก้อนตามพฤติกรรม native (ไม่ใช่เหลือก้อนซ้าย)
const T = R(100, 100, 200, 12); // บรรทัดแรก
const M1 = R(50, 120, 250, 12); // บรรทัดกลางก้อนซ้าย
const M2 = R(310, 120, 440, 12); // บรรทัดกลางก้อนขวา (justify gap 10px)
const B = R(50, 140, 700, 12); // บรรทัดล่าง
const kept = rectsInSweptPath([T, M1, M2, B], { x: 120, y: 106 }, { x: 150, y: 146 });
expect(kept).toEqual([T, M1, M2, B]);
});
it("ลากใน margin ซ้ายของบล็อกข้อความ: บรรทัดกลางยังเก็บเต็มบรรทัด (code review 2026-10-05)", () => {
// จุดลาก x=20 อยู่หลังขอบซ้ายข้อความ (50) — บรรทัดกลางต้องไม่หายทั้งบรรทัด
const A = R(50, 100, 100, 12);
const C = R(50, 140, 100, 12);
const mid = R(50, 120, 120, 12);
const kept = rectsInSweptPath([A, mid, C], { x: 20, y: 106 }, { x: 22, y: 146 });
expect(kept).toContain(mid);
});
});
describe("rectsInSweptPath — snap จุดเข้าบรรทัดใกล้สุดก่อนตัดสิน (A3 2026-09-28)", () => {
// พิกัดนิ้วดิบที่ห่าง center บรรทัดเพียงเล็กน้อยแต่คนละบรรทัด เคยถูกตีเป็น
// "บรรทัดเดียวกัน" จนกรองได้ 0 rect — ชุดนี้ยืนยันว่า snap แล้วผลถูกต้อง
// (บรรทัดสูง 20 ระยะห่าง 24 → center = 24*i + 10 + dy, lineTol = 12)
const R2 = (x: number, y: number, width: number, height: number) =>
({ x, y, width, height }) as DOMRect;
const L = (i: number, x0: number, x1: number, dy = 0) =>
R2(x0, 24 * i + dy, x1 - x0, 20);
it("ลากลงหลายบรรทัด มีบรรทัดรบกวนนอกเส้นทาง: เก็บเฉพาะช่วงที่ลากผ่าน", () => {
const noise = [L(0, 0, 500), L(7, 0, 500)];
const ml = [L(1, 200, 500), L(2, 0, 500), L(3, 0, 500), L(4, 0, 300)];
expect(
rectsInSweptPath([...noise, ...ml], { x: 200, y: 34 }, { x: 300, y: 106 }),
).toHaveLength(4);
});
it("ลากขึ้นหลายบรรทัด (ทิศกลับ): ผลเหมือนลากลง", () => {
const noise = [L(0, 0, 500), L(7, 0, 500)];
const ml = [L(1, 200, 500), L(2, 0, 500), L(3, 0, 500), L(4, 0, 300)];
expect(
rectsInSweptPath([...noise, ...ml], { x: 300, y: 106 }, { x: 200, y: 34 }),
).toHaveLength(4);
});
it("ลากย้อนในบรรทัดเดียว (ขวา→ซ้าย): เก็บ rect ที่เส้นทางพาด", () => {
const rects = [L(0, 0, 500), L(1, 100, 400), L(7, 0, 500)];
expect(
rectsInSweptPath(rects, { x: 400, y: 34 }, { x: 100, y: 34 }),
).toHaveLength(1);
});
it("2 บรรทัด dy ใหญ่: เก็บทั้งคู่ (ท้ายบรรทัดบน → ต้นบรรทัดล่าง)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 400, y: 34 }, { x: 100, y: 58 }),
).toHaveLength(2);
});
it("2 บรรทัด dy เล็ก (พิกัดนิ้วไม่ตรง center — เดิมได้ 0 rect)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 400, y: 42 }, { x: 100, y: 50 }),
).toHaveLength(2);
});
it("2 บรรทัด dy เล็ก ลากขึ้น (เดิมได้ 0 rect)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 100, y: 50 }, { x: 400, y: 42 }),
).toHaveLength(2);
});
it("หลัง auto-scroll 300px: anchor ที่ชดเชยแล้ว (สัดส่วนของหน้า A2) ต้องได้ครบทุกบรรทัด", () => {
const sc = -300;
const rects = [L(0, 100, 500, sc + 100)];
for (let i = 1; i <= 24; i++) rects.push(L(i, 0, 500, sc + 100));
rects.push(L(25, 0, 300, sc + 100));
expect(
rectsInSweptPath(rects, { x: 100, y: 110 + sc }, { x: 300, y: 400 }),
).toHaveLength(26);
});
});