import { describe, expect, it } from "vitest"; import type { AnnotationData, ShapeData } from "@/db/schema"; import { STAMP_HEIGHT_RATIO, annotationBBox, hitTestPath, hitTestShape, scaleAnnotationData, translateAnnotationData, } from "@/utils/geometry"; // เคล็ดลับจาก docstring ของ annotationBBox: size 100×100 ทำให้ px = % โดยตรง const S = { width: 100, height: 100 }; describe("annotationBBox", () => { it("HIGHLIGHT: union ของ rects ทุกอัน", () => { const bbox = annotationBBox( { type: "HIGHLIGHT", data: { text: "ทดสอบ", color: "#fde047", rects: [ { x: 10, y: 20, width: 30, height: 5 }, { x: 45, y: 22, width: 20, height: 5 }, ], }, }, S, ); expect(bbox).toEqual({ x: 10, y: 20, width: 55, height: 7 }); }); it("HIGHLIGHT: rects ว่าง → กล่องศูนย์", () => { const bbox = annotationBBox( { type: "HIGHLIGHT", data: { text: "", color: "#000", rects: [] } }, S, ); expect(bbox).toEqual({ x: 0, y: 0, width: 0, height: 0 }); }); it("DRAW: ขยายจุดสุดขอบด้วย strokeWidth/2", () => { const bbox = annotationBBox( { type: "DRAW", data: { strokeColor: "#ef4444", strokeWidth: 2, paths: [ { x: 10, y: 10 }, { x: 30, y: 50 }, ], }, }, S, ); expect(bbox).toEqual({ x: 9, y: 9, width: 22, height: 42 }); }); it("COMMENT: กล่องสี่เหลี่ยมรอบ position ขนาด COMMENT_PIN_BOX_PCT", () => { const bbox = annotationBBox( { type: "COMMENT", data: { position: { x: 50, y: 50 }, message: "สวัสดี" } }, S, ); expect(bbox).toEqual({ x: 48, y: 48, width: 4, height: 4 }); }); it("SHAPE rect: ขยายออกด้วยครึ่งความหนาเส้น", () => { const bbox = annotationBBox( { type: "SHAPE", data: { shape: "rect", rect: { x: 10, y: 20, width: 30, height: 10 }, strokeColor: "#000", strokeWidth: 2, }, }, S, ); expect(bbox).toEqual({ x: 9, y: 19, width: 32, height: 12 }); }); it("SHAPE line: bbox ของ from/to ขยายครึ่งความหนา (ไม่ใช่ rect)", () => { const bbox = annotationBBox( { type: "SHAPE", data: { shape: "line", from: { x: 10, y: 10 }, to: { x: 40, y: 20 }, strokeColor: "#000", strokeWidth: 1, }, }, S, ); expect(bbox).toEqual({ x: 9.5, y: 9.5, width: 31, height: 11 }); }); it("REDACTION: rect เดิมตรง ๆ ไม่ขยายความหนา", () => { const bbox = annotationBBox( { type: "REDACTION", data: { rect: { x: 10, y: 20, width: 30, height: 10 } } }, S, ); expect(bbox).toEqual({ x: 10, y: 20, width: 30, height: 10 }); }); it("TEXTBOX: ประมาณจากจำนวนตัวอักษร × 0.6 fontSize บรรทัดสูง 1.5 fontSize", () => { const bbox = annotationBBox( { type: "TEXTBOX", data: { position: { x: 10, y: 20 }, text: "อบจ", color: "#111827", fontSize: 2 }, }, S, ); expect(bbox.x).toBe(10); expect(bbox.y).toBe(20); expect(bbox.width).toBeCloseTo(3.6); // 3 ตัว × (2 × 0.6) expect(bbox.height).toBeCloseTo(3); // 1.5 × 2 }); it("TEXTBOX: ข้อความกว้างเกิน 50% ของหน้า → wrap เป็นหลายบรรทัด กว้างถูก cap", () => { const bbox = annotationBBox( { type: "TEXTBOX", data: { position: { x: 0, y: 0 }, text: "x".repeat(60), color: "#000", fontSize: 2 }, }, S, ); expect(bbox.width).toBe(50); // 60 × 1.2 = 72 → cap ที่ 50 expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3 }); it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => { const bbox = annotationBBox( { type: "TEXTBOX", data: { position: { x: 10, y: 20 }, text: "", color: "#000", fontSize: 2 }, }, S, ); expect(bbox.width).toBeCloseTo(1.2); expect(bbox.height).toBeCloseTo(3); }); it("STAMP: กว้าง = scale% สูง = กว้าง × STAMP_HEIGHT_RATIO", () => { const bbox = annotationBBox( { type: "STAMP", data: { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 }, }, S, ); expect(bbox).toEqual({ x: 10, y: 20, width: 18, height: 18 * STAMP_HEIGHT_RATIO }); }); }); describe("translateAnnotationData", () => { const cases: { name: string; data: AnnotationData; dx: number; dy: number }[] = [ { name: "HIGHLIGHT เลื่อนทุก rect", data: { text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 5, height: 5 }] }, dx: 5, dy: 3, }, { name: "DRAW เลื่อนทุกจุด", data: { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 1, y: 2 }] }, dx: 5, dy: 3, }, { name: "SHAPE line เลื่อนทั้ง from/to", data: { shape: "line", from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, strokeColor: "#000", strokeWidth: 1 }, dx: 5, dy: 3, }, { name: "REDACTION เลื่อน rect", data: { rect: { x: 10, y: 10, width: 5, height: 5 } }, dx: 5, dy: 3, }, { name: "STAMP เลื่อน position", data: { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 }, dx: 5, dy: 3, }, ]; // ตรวจผ่าน annotationBBox (มันแยกชนิดจาก field ของ data ไม่ใช่ค่า type จึงส่ง type อะไรมาก็ได้) // → ยืนยันว่า "ทั้งชิ้น" เลื่อนไป dx,dy พอดี ไม่ต้องเขียน assertion แยกทีละ variant it.each(cases)("%s", ({ data, dx, dy }) => { const out = translateAnnotationData(data, dx, dy); const before = annotationBBox({ type: "DRAW", data }, S); const after = annotationBBox({ type: "DRAW", data: out }, S); expect(after.x).toBeCloseTo(before.x + dx); expect(after.y).toBeCloseTo(before.y + dy); }); it("COMMENT เลื่อน position", () => { const out = translateAnnotationData({ position: { x: 10, y: 10 }, message: "m" }, 5, 3); expect(out).toEqual({ position: { x: 15, y: 13 }, message: "m" }); }); it("DRAW: strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)", () => { const out = translateAnnotationData( { strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] }, 10, 10, ); expect(out).toMatchObject({ strokeWidth: 2 }); }); }); describe("scaleAnnotationData", () => { it("HIGHLIGHT anchor {0,0} ×2: ทุกพิกัดและขนาด ×2", () => { const out = scaleAnnotationData( { text: "a", color: "#000", rects: [{ x: 10, y: 20, width: 5, height: 6 }] }, { x: 0, y: 0 }, 2, 2, ); if (!("rects" in out)) throw new Error("expected rects"); expect(out.rects[0]).toEqual({ x: 20, y: 40, width: 10, height: 12 }); }); it("HIGHLIGHT ย่อ–ขยายรอบ anchor มุมตรงข้าม: ขอบที่ anchor อยู่นิ่ง", () => { const out = scaleAnnotationData( { text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 20, height: 10 }] }, { x: 30, y: 20 }, 2, 1, ); if (!("rects" in out)) throw new Error("expected rects"); expect(out.rects[0]).toEqual({ x: -10, y: 10, width: 40, height: 10 }); // ขอบขวา: -10 + 40 = 30 = anchor.x ✓ · y และ height ไม่เปลี่ยนเพราะ sy=1 }); it("COMMENT position ขยายจาก anchor", () => { const out = scaleAnnotationData({ position: { x: 30, y: 10 }, message: "m" }, { x: 10, y: 10 }, 2, 1); expect(out).toEqual({ position: { x: 50, y: 10 }, message: "m" }); }); it("STAMP anchor มุมตรงข้าม ×2: position ขยายจาก anchor และ scale ×2", () => { // bbox เดิม x 10 y 20 กว้าง 18 สูง 7.2 → anchor = มุมขวาล่าง (28, 27.2) const out = scaleAnnotationData( { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 }, { x: 28, y: 27.2 }, 2, 2, ); expect(out).toEqual({ label: "สำคัญ", position: { x: -8, y: 12.8 }, scale: 36 }); }); it("STAMP หลังขยาย ×k รอบ anchor: bbox ใหม่ = ขอบเดิม ×k รอบ anchor (กว้าง-สูงผูกกัน)", () => { const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 }; const anchor = { x: 28, y: 27.2 }; const out = scaleAnnotationData(data, anchor, 1.5, 1.5); const before = annotationBBox({ type: "STAMP", data }, S); const after = annotationBBox({ type: "STAMP", data: out }, S); expect(after.width).toBeCloseTo(before.width * 1.5); expect(after.height).toBeCloseTo(before.height * 1.5); // สัดส่วน 0.4 คงเดิม expect(after.x + after.width).toBeCloseTo(anchor.x); // ขอบที่ anchor อยู่นิ่ง expect(after.y + after.height).toBeCloseTo(anchor.y); }); it("DRAW: strokeWidth คงเดิมตามสเปก F1", () => { const out = scaleAnnotationData( { strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] }, { x: 0, y: 0 }, 3, 3, ); expect(out).toMatchObject({ strokeWidth: 2 }); }); }); describe("hitTestPath", () => { const S2 = { width: 100, height: 100 }; // % = px โดยตรง it("ไม่มีจุดเลย → false", () => { expect(hitTestPath([], { x: 50, y: 50 }, 5, S2)).toBe(false); }); it("จุดเดียว: อยู่ใน threshold → true / เกิน → false", () => { expect(hitTestPath([{ x: 50, y: 50 }], { x: 52, y: 50 }, 5, S2)).toBe(true); expect(hitTestPath([{ x: 50, y: 50 }], { x: 60, y: 50 }, 5, S2)).toBe(false); }); it("วัดระยะถึง segment กลางเส้น ไม่ใช่แค่ endpoint", () => { const path = [ { x: 10, y: 10 }, { x: 90, y: 10 }, ]; expect(hitTestPath(path, { x: 50, y: 12 }, 5, S2)).toBe(true); expect(hitTestPath(path, { x: 50, y: 30 }, 5, S2)).toBe(false); }); it("จุดอยู่ต่อเส้นแต่นอกช่วง segment → วัดถึง endpoint ที่ใกล้สุด", () => { const path = [ { x: 10, y: 10 }, { x: 90, y: 10 }, ]; expect(hitTestPath(path, { x: 0, y: 10 }, 5, S2)).toBe(false); // ห่าง endpoint 10px expect(hitTestPath(path, { x: 7, y: 10 }, 5, S2)).toBe(true); // ห่าง 3px }); }); describe("hitTestShape", () => { const S2 = { width: 100, height: 100 }; it("rect: กลางพื้นที่ไม่โดน (hit เฉพาะเส้นขอบ)", () => { const data: ShapeData = { shape: "rect", rect: { x: 20, y: 20, width: 60, height: 40 }, strokeColor: "#000", strokeWidth: 1, }; expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false); }); it("rect: จุดบนขอบ → true / นอกกรอบห่างเกิน threshold → false", () => { const data: ShapeData = { shape: "rect", rect: { x: 20, y: 20, width: 60, height: 40 }, strokeColor: "#000", strokeWidth: 1, }; expect(hitTestShape(data, { x: 50, y: 20 }, 3, S2)).toBe(true); expect(hitTestShape(data, { x: 50, y: 15 }, 3, S2)).toBe(false); // ห่างขอบ 5px }); it("ellipse: จุดบนเส้นรอบวงรี → true / กลางวง → false", () => { const data: ShapeData = { shape: "ellipse", rect: { x: 20, y: 20, width: 60, height: 40 }, strokeColor: "#000", strokeWidth: 1, }; // center (50,40) rx=30 ry=20 → จุด (80,40) อยู่บนวงรีพอดี expect(hitTestShape(data, { x: 80, y: 40 }, 3, S2)).toBe(true); expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false); }); it("line/arrow: วัดระยะถึงเส้นตรง from→to", () => { const data: ShapeData = { shape: "arrow", from: { x: 10, y: 10 }, to: { x: 50, y: 50 }, strokeColor: "#000", strokeWidth: 1, }; expect(hitTestShape(data, { x: 31, y: 31 }, 3, S2)).toBe(true); // ใกล้เส้น ~1.4px expect(hitTestShape(data, { x: 80, y: 80 }, 3, S2)).toBe(false); }); });