/** * Tests for useSlideWordPositions hook. * * The hook computes word positions from OCR word_boxes or pixel projection. * Since Canvas/Image are not available in jsdom, we test the pure computation * logic by extracting and verifying the WordPosition interface contract. */ import { describe, it, expect } from 'vitest' // --------------------------------------------------------------------------- // WordPosition interface (mirrored from useSlideWordPositions.ts) // --------------------------------------------------------------------------- interface WordPosition { xPct: number wPct: number yPct: number hPct: number text: string fontRatio: number } // --------------------------------------------------------------------------- // Pure computation functions extracted from the hook for testing // --------------------------------------------------------------------------- /** * Word-box path: compute WordPosition from an OCR word_box. * Replicates the word_boxes.map() logic in useSlideWordPositions. */ function wordBoxToPosition( box: { text: string; left: number; top: number; width: number; height: number }, imgW: number, imgH: number, ): WordPosition { return { xPct: (box.left / imgW) * 100, wPct: (box.width / imgW) * 100, yPct: (box.top / imgH) * 100, hPct: (box.height / imgH) * 100, text: box.text, fontRatio: 1.0, } } /** * Fallback path (no word_boxes): spread tokens evenly across cell bbox. * Replicates the fallback logic in useSlideWordPositions. */ function fallbackPositions( tokens: string[], bboxPct: { x: number; y: number; w: number; h: number }, ): WordPosition[] { const fallbackW = bboxPct.w / tokens.length return tokens.map((t, i) => ({ xPct: bboxPct.x + i * fallbackW, wPct: fallbackW, yPct: bboxPct.y, hPct: bboxPct.h, text: t, fontRatio: 1.0, })) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('wordBoxToPosition (word-box path)', () => { it('should compute percentage positions from pixel coordinates', () => { const box = { text: 'hello', left: 100, top: 200, width: 80, height: 20 } const wp = wordBoxToPosition(box, 1000, 2000) expect(wp.xPct).toBeCloseTo(10, 1) // 100/1000 * 100 expect(wp.wPct).toBeCloseTo(8, 1) // 80/1000 * 100 expect(wp.yPct).toBeCloseTo(10, 1) // 200/2000 * 100 expect(wp.hPct).toBeCloseTo(1, 1) // 20/2000 * 100 expect(wp.text).toBe('hello') expect(wp.fontRatio).toBe(1.0) }) it('should produce different yPct for words on different lines', () => { const imgW = 1000, imgH = 2000 const word1 = wordBoxToPosition({ text: 'line1', left: 50, top: 100, width: 60, height: 20 }, imgW, imgH) const word2 = wordBoxToPosition({ text: 'line2', left: 50, top: 130, width: 60, height: 20 }, imgW, imgH) expect(word1.yPct).not.toEqual(word2.yPct) expect(word2.yPct).toBeGreaterThan(word1.yPct) }) it('should handle word at origin', () => { const wp = wordBoxToPosition({ text: 'a', left: 0, top: 0, width: 50, height: 25 }, 500, 500) expect(wp.xPct).toBe(0) expect(wp.yPct).toBe(0) expect(wp.wPct).toBeCloseTo(10, 1) expect(wp.hPct).toBeCloseTo(5, 1) }) it('should handle word at bottom-right corner', () => { const wp = wordBoxToPosition({ text: 'z', left: 900, top: 1900, width: 100, height: 100 }, 1000, 2000) expect(wp.xPct).toBe(90) expect(wp.yPct).toBe(95) expect(wp.wPct).toBe(10) expect(wp.hPct).toBe(5) }) }) describe('fallbackPositions (no word_boxes)', () => { it('should spread tokens evenly across cell width', () => { const bbox = { x: 10, y: 20, w: 60, h: 5 } const positions = fallbackPositions(['apple', 'Apfel'], bbox) expect(positions.length).toBe(2) expect(positions[0].xPct).toBeCloseTo(10, 1) expect(positions[1].xPct).toBeCloseTo(40, 1) // 10 + 30 expect(positions[0].wPct).toBeCloseTo(30, 1) expect(positions[1].wPct).toBeCloseTo(30, 1) }) it('should use cell bbox for Y position (all words same Y)', () => { const bbox = { x: 5, y: 30, w: 80, h: 4 } const positions = fallbackPositions(['a', 'b', 'c'], bbox) for (const wp of positions) { expect(wp.yPct).toBe(30) expect(wp.hPct).toBe(4) } }) it('should handle single token', () => { const bbox = { x: 15, y: 25, w: 50, h: 6 } const positions = fallbackPositions(['word'], bbox) expect(positions.length).toBe(1) expect(positions[0].xPct).toBe(15) expect(positions[0].wPct).toBe(50) expect(positions[0].yPct).toBe(25) expect(positions[0].hPct).toBe(6) }) }) describe('WordPosition yPct/hPct contract', () => { it('word-box path: yPct comes from box.top, not cell bbox', () => { // This is the key fix: multi-line cells should NOT stack words at cell center const cellBbox = { x: 10, y: 20, w: 60, h: 10 } // cell spans y=20% to y=30% const imgW = 1000, imgH = 1000 // Two words on different lines within the same cell const word1 = wordBoxToPosition({ text: 'line1', left: 100, top: 200, width: 80, height: 20 }, imgW, imgH) const word2 = wordBoxToPosition({ text: 'line2', left: 100, top: 260, width: 80, height: 20 }, imgW, imgH) // word1 should be at y=20%, word2 at y=26% — NOT both at cellBbox.y (20%) expect(word1.yPct).toBeCloseTo(20, 1) expect(word2.yPct).toBeCloseTo(26, 1) expect(word1.yPct).not.toEqual(word2.yPct) // Both should have individual heights from their box, not cell height expect(word1.hPct).toBeCloseTo(2, 1) expect(word2.hPct).toBeCloseTo(2, 1) // Cell height would be 10% — word height is 2%, confirming per-word sizing expect(word1.hPct).toBeLessThan(cellBbox.h) }) it('fallback path: yPct equals cell bbox.y (no per-word data)', () => { const bbox = { x: 10, y: 45, w: 30, h: 8 } const positions = fallbackPositions(['a', 'b'], bbox) // Without word_boxes, all words use cell bbox Y — expected behavior expect(positions[0].yPct).toBe(bbox.y) expect(positions[1].yPct).toBe(bbox.y) expect(positions[0].hPct).toBe(bbox.h) expect(positions[1].hPct).toBe(bbox.h) }) })