eCourt-note/tests/utils/coordinates.test.ts
Your Name 45e60330b8 highlight-touch: แก้กลุ่มบั๊กจอสัมผัสของ useCustomSelection + rectsInSweptPath ตามรายงาน review 12 ข้อ + follow-up 3 ข้อ — ลากแนวนอนบรรทัดเดียวบน tablet ต้องสร้างมาร์กได้ทุกครั้ง และ gesture ต้องไม่ตายถาวรกลางsession — (1) fingerOffsetFor: FINGER_OFFSET_PX=12 คงที่ทำ probe ที่ y−12 หลุดบรรทัด (บรรทัด PDF บน tablet zoom-fit สูงจริง ~14px — วัดจาก viewport 820px) ไปโดน heading จนลากแนวนอนเงียบไม่สร้าง — offset กลายเป็น min(12, ครึ่งความสูงบรรทัด−2) วัดจาก span ของ anchor ตอน pointerdown (บรรทัด ≥28px ยังได้ 12 เท่าเดิม) และถ้าน้อยกว่า 12 จะ resolve anchor ซ้ำที่จุดที่หักจริง — ยืนยันด้วย Playwright emulate iPad + CDP touch จริง (ก่อนแก้ 9/11 หลังแก้ 11/11) (2) A1 rangeBetween ฝั่งหลังใช้ boundary end แทน start — anchor/focus ผ่าน word snap แล้วเป็น range ครอบคำ การอ่าน start ทิ้งคำท้าย (ลาก alpha→gamma ได้แค่ alpha beta) และลากในคำเดียวได้ selection ว่าง (3) A2 anchorPathPt(client คงที่)→anchorRel สัดส่วน 0–1 ของหน้า แปลงกลับทุกเฟรม — พิกัด client โกหกทันทีที่ auto-scroll เลื่อนเนื้อหากลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก (จำลอง 26 บรรทัดเคยได้ 14) (4) A3 rectsInSweptPath (utils/coordinates.ts) rewrite: snap จุด start/end เข้า center บรรทัดใกล้สุด (snapY) ก่อนตัดสินบรรทัดเดียวกัน/คนละบรรทัด — พิกัดนิ้วที่คนละบรรทัดแต่ dy น้อยกว่า lineTol เคยถูกตีเป็นบรรทัดเดียวจนกรองได้ 0 rect (ลากท้ายบรรทัดบน→ต้นบรรทัดถัดไประยะสั้น) — ใช้ร่วม desktop เหมือนเดิม เคส test เดิม 3 เคสยังผ่าน (5) A4 range.collapsed (ย่อจนว่างจริง) = เคลียร์ lastRef + preview แล้ว return — commit ตรง preview เสมอ แยกจากเคสกรองว่างชั่วคราวที่ยังคง lastRef ตามเจตนาเดิม (6) A5 caretInSpanAt วาง caret ตามสัดส่วน x ใน span แทนต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว) ใช้ทั้งชั้น ③ elementsFromPoint และ ④ nearest span (7) A6 fromElement ปฏิเสธ text node ที่อยู่นอก .textLayer (กัน DFS หลุดไป text node แรกของหน้า) (8) A7.1 auto-scroll speed cap ระยะเลยขอบที่ margin / A7.2 ลบ seenMovePointerIds debug / A7.4 mouse ปุ่มซ้ายเท่านั้น (hybrid) / A7.5 ส่ง DragState ตรงให้ resolveTextCaretDeep ให้ spanCache ถูกเก็บกลับ (9) B1 ระหว่างลาก retry resolver แค่ 2 ทิศแรก (ครบ 8 ทิศ ~6ms/เฟรม เก็บไว้ pointerdown — fail ระหว่างลากมีทางออกคือคง lastFocus) (10) B2+2.3 spanCache เก็บ fraction 0–1 ของหน้า (แบบเดียวกับ anchorRel — ทน re-fit ความกว้างกลาง gesture เช่น นิ้วที่สอง toggle sidebar) วัดครั้งเดียวต่อ gesture แทน getBoundingClientRect ทั้งหน้าทุกครั้ง (~2.3ms) (11) code review 2.1: pointerup/pointercancel ย้ายจาก <main> เป็นตาข่าย document แบบ capture phase ผูกตั้งแต่ pointerdown จน finish — Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง พอ capture หาด pointerup ยิงนอก <main> จน dragRef ค้าง gesture ถัดไปเงียบหมด (arm เฉพาะตอน active ไม่พอ — tap ที่ไม่ขยับก็ต้องมีตาข่ายรับ ไม่งั้นค้างเหมือนกัน เจอจริงตอนแก้ e2e 9/13) (12) A7.3 ไม่ใช้: ห้ามผูก finish เข้า lostpointercapture — กับ touch ของ Chromium ยิงกลาง drag ได้เอง (implicit capture ของ span + re-hit-test) ผูกแล้ว dragRef ถูกเคลียร์กลางทาง gesture ตายหมด — ยืนยันด้วย CDP touch event log จริง (13) 2.2 แก้ comment fingerOffsetFor ให้ตรงกลไก (เคส probe โดน heading ≥28px ยังใช้ heuristic เดิม) (14) tests: coordinates +7 เคส (รวม 2 บรรทัด dy เล็กที่เคยได้ 0 และ auto-scroll 26 บรรทัด) + ไฟล์ใหม่ tests/hooks/useCustomSelection-dom.test.ts 7 เคส snapCaretToWord/rangeBetween (export เพิ่มเพื่อ test พร้อม comment) ผ่าน pragma @vitest-environment jsdom รวมคำไทยกับ Intl.Segmenter "แมวกินปลา"→"แมว" — เพิ่ม devDependency jsdom (test-only) (15) rules/pdf-viewer.md sync จุดที่พฤติกรรมเปลี่ยนครบ — ผ่าน code review รอบ 2 (ไม่มี bug ระดับต้องแก้) + tsc + vitest 157/157 + e2e tablet 12/13 (เคส F fail ที่การจัดเตรียมเงื่อนไขของ script เอง ไม่ใช่ฟังก์ชัน — A3 ครอบโดย unit tests) (คำขอผู้ใช้ 2026-09-28)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:01:09 +07:00

256 lines
12 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 {
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]);
});
});
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);
});
});