256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|