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