/** * Tests for usePixelWordPositions hook. * * The hook performs pixel-based word positioning using an offscreen canvas. * Since Canvas/getImageData is not available in jsdom, we test the pure * computation logic by extracting and testing the algorithms directly. */ import { describe, it, expect } from 'vitest' // --------------------------------------------------------------------------- // Extract pure computation functions from the hook for testing // --------------------------------------------------------------------------- interface Cluster { start: number end: number } /** * Cluster detection: find runs of dark pixels above a threshold. * Replicates the cluster detection logic in usePixelWordPositions. */ function findClusters(proj: number[], ch: number, cw: number): Cluster[] { const threshold = Math.max(1, ch * 0.03) const minGap = Math.max(5, Math.round(cw * 0.02)) const clusters: Cluster[] = [] let inCluster = false let clStart = 0 let gap = 0 for (let x = 0; x < cw; x++) { if (proj[x] >= threshold) { if (!inCluster) { clStart = x; inCluster = true } gap = 0 } else if (inCluster) { gap++ if (gap > minGap) { clusters.push({ start: clStart, end: x - gap }) inCluster = false gap = 0 } } } if (inCluster) clusters.push({ start: clStart, end: cw - 1 - gap }) return clusters } /** * Mirror clusters for 180° rotation. * Replicates the rotation logic in usePixelWordPositions. */ function mirrorClusters(clusters: Cluster[], cw: number): Cluster[] { return clusters.map(c => ({ start: cw - 1 - c.end, end: cw - 1 - c.start, })).reverse() } /** * Compute fontRatio from cluster width, measured text width, and cell height. * Replicates the font ratio calculation. */ function computeFontRatio( clusterW: number, measuredWidth: number, refFontSize: number, ch: number, ): number { const autoFontPx = refFontSize * (clusterW / measuredWidth) return Math.min(autoFontPx / ch, 1.0) } /** * Mode normalization: find the most common fontRatio (bucketed to 0.02). * Replicates the mode normalization in usePixelWordPositions. */ function normalizeFontRatios(ratios: number[]): number { if (ratios.length === 0) return 0 const buckets = new Map() for (const r of ratios) { const key = Math.round(r * 50) / 50 buckets.set(key, (buckets.get(key) || 0) + 1) } let modeRatio = ratios[0] let modeCount = 0 for (const [ratio, count] of buckets) { if (count > modeCount) { modeRatio = ratio; modeCount = count } } return modeRatio } /** * Coordinate transform for 180° rotation. */ function transformCellCoords180( x: number, y: number, w: number, h: number, imgW: number, imgH: number, ): { cx: number; cy: number } { return { cx: Math.round((100 - x - w) / 100 * imgW), cy: Math.round((100 - y - h) / 100 * imgH), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('findClusters', () => { it('should find a single cluster', () => { // Simulate a projection with dark pixels from x=10 to x=50 const proj = new Array(100).fill(0) for (let x = 10; x <= 50; x++) proj[x] = 10 const clusters = findClusters(proj, 100, 100) expect(clusters.length).toBe(1) expect(clusters[0].start).toBe(10) expect(clusters[0].end).toBe(50) }) it('should find multiple clusters separated by gaps', () => { const proj = new Array(200).fill(0) // Two word groups with a gap between for (let x = 10; x <= 40; x++) proj[x] = 10 for (let x = 80; x <= 120; x++) proj[x] = 10 const clusters = findClusters(proj, 100, 200) expect(clusters.length).toBe(2) expect(clusters[0].start).toBe(10) expect(clusters[1].start).toBe(80) }) it('should merge clusters with small gaps', () => { // Gap smaller than minGap should not split clusters const proj = new Array(100).fill(0) for (let x = 10; x <= 30; x++) proj[x] = 10 // Small gap (3px) — minGap = max(5, 100*0.02) = 5 for (let x = 34; x <= 50; x++) proj[x] = 10 const clusters = findClusters(proj, 100, 100) expect(clusters.length).toBe(1) // merged into one cluster }) it('should return empty for all-white projection', () => { const proj = new Array(100).fill(0) const clusters = findClusters(proj, 100, 100) expect(clusters.length).toBe(0) }) }) describe('mirrorClusters', () => { it('should mirror clusters for 180° rotation', () => { const clusters: Cluster[] = [ { start: 10, end: 50 }, { start: 80, end: 120 }, ] const cw = 200 const mirrored = mirrorClusters(clusters, cw) // Cluster at (10,50) → (cw-1-50, cw-1-10) = (149, 189) // Cluster at (80,120) → (cw-1-120, cw-1-80) = (79, 119) // After reverse: [(79,119), (149,189)] expect(mirrored.length).toBe(2) expect(mirrored[0]).toEqual({ start: 79, end: 119 }) expect(mirrored[1]).toEqual({ start: 149, end: 189 }) }) it('should maintain left-to-right order after mirroring', () => { const clusters: Cluster[] = [ { start: 5, end: 30 }, { start: 50, end: 80 }, { start: 100, end: 130 }, ] const mirrored = mirrorClusters(clusters, 200) // After mirroring and reversing, order should be left-to-right for (let i = 1; i < mirrored.length; i++) { expect(mirrored[i].start).toBeGreaterThan(mirrored[i - 1].start) } }) it('should handle single cluster', () => { const clusters: Cluster[] = [{ start: 20, end: 80 }] const mirrored = mirrorClusters(clusters, 200) expect(mirrored.length).toBe(1) expect(mirrored[0]).toEqual({ start: 119, end: 179 }) }) }) describe('computeFontRatio', () => { it('should compute ratio based on cluster vs measured width', () => { // Cluster is 100px wide, measured text at 40px font is 200px → autoFont = 20px // Cell height = 30px → ratio = 20/30 = 0.667 const ratio = computeFontRatio(100, 200, 40, 30) expect(ratio).toBeCloseTo(0.667, 2) }) it('should cap ratio at 1.0', () => { // Very large cluster relative to measured text const ratio = computeFontRatio(400, 100, 40, 30) expect(ratio).toBe(1.0) }) it('should handle small cluster width', () => { const ratio = computeFontRatio(10, 200, 40, 30) expect(ratio).toBeCloseTo(0.067, 2) }) }) describe('normalizeFontRatios', () => { it('should return the most common ratio', () => { const ratios = [0.5, 0.5, 0.5, 0.3, 0.3, 0.7] const mode = normalizeFontRatios(ratios) expect(mode).toBe(0.5) }) it('should bucket ratios to nearest 0.02', () => { // 0.51 and 0.49 both round to 0.50 (nearest 0.02) const ratios = [0.51, 0.49, 0.50, 0.30] const mode = normalizeFontRatios(ratios) expect(mode).toBe(0.50) }) it('should handle empty array', () => { expect(normalizeFontRatios([])).toBe(0) }) it('should handle single ratio', () => { expect(normalizeFontRatios([0.65])).toBe(0.66) // rounded to nearest 0.02 }) }) describe('transformCellCoords180', () => { it('should transform cell coordinates for 180° rotation', () => { // Cell at x=10%, y=20%, w=30%, h=5% on a 1000x2000 image const { cx, cy } = transformCellCoords180(10, 20, 30, 5, 1000, 2000) // Expected: cx = (100 - 10 - 30) / 100 * 1000 = 600 // cy = (100 - 20 - 5) / 100 * 2000 = 1500 expect(cx).toBe(600) expect(cy).toBe(1500) }) it('should handle cell at origin', () => { const { cx, cy } = transformCellCoords180(0, 0, 50, 50, 1000, 1000) // Expected: cx = (100 - 0 - 50) / 100 * 1000 = 500 // cy = (100 - 0 - 50) / 100 * 1000 = 500 expect(cx).toBe(500) expect(cy).toBe(500) }) it('should handle cell at bottom-right', () => { const { cx, cy } = transformCellCoords180(80, 90, 20, 10, 1000, 2000) // Expected: cx = (100 - 80 - 20) / 100 * 1000 = 0 // cy = (100 - 90 - 10) / 100 * 2000 = 0 expect(cx).toBe(0) expect(cy).toBe(0) }) }) describe('sub-session coordinate conversion', () => { /** * Test the coordinate conversion from sub-session (box-relative) * to parent (page-absolute) coordinates. * Replicates the logic in StepReconstruction loadSessionData. */ it('should convert sub-session cell coords to parent space', () => { const imgW = 1746 const imgH = 2487 // Box zone in pixels const box = { x: 50, y: 1145, width: 1100, height: 270 } // Box in percent const boxXPct = (box.x / imgW) * 100 const boxYPct = (box.y / imgH) * 100 const boxWPct = (box.width / imgW) * 100 const boxHPct = (box.height / imgH) * 100 // Sub-session cell at (10%, 20%, 80%, 15%) relative to box const subCell = { x: 10, y: 20, w: 80, h: 15 } const parentX = boxXPct + (subCell.x / 100) * boxWPct const parentY = boxYPct + (subCell.y / 100) * boxHPct const parentW = (subCell.w / 100) * boxWPct const parentH = (subCell.h / 100) * boxHPct // Box start in percent: x ≈ 2.86%, y ≈ 46.04% expect(parentX).toBeCloseTo(boxXPct + 0.1 * boxWPct, 2) expect(parentY).toBeCloseTo(boxYPct + 0.2 * boxHPct, 2) expect(parentW).toBeCloseTo(0.8 * boxWPct, 2) expect(parentH).toBeCloseTo(0.15 * boxHPct, 2) // All values should be within 0-100% expect(parentX).toBeGreaterThan(0) expect(parentY).toBeGreaterThan(0) expect(parentX + parentW).toBeLessThan(100) expect(parentY + parentH).toBeLessThan(100) }) it('should place sub-cell at box origin when sub coords are 0,0', () => { const imgW = 1000 const imgH = 2000 const box = { x: 100, y: 500, width: 800, height: 200 } const boxXPct = (box.x / imgW) * 100 // 10% const boxYPct = (box.y / imgH) * 100 // 25% const parentX = boxXPct + (0 / 100) * ((box.width / imgW) * 100) const parentY = boxYPct + (0 / 100) * ((box.height / imgH) * 100) expect(parentX).toBeCloseTo(10, 1) expect(parentY).toBeCloseTo(25, 1) }) })