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]); }); });