Files
breakpilot-lehrer/admin-lehrer/components/ocr-pipeline/__tests__/usePixelWordPositions.test.ts
Benjamin Admin f39cbe9283 refactor: remove unused pages and backends (model-management, OCR legacy, GPU/vast.ai, video-chat, matrix)
Deleted pages:
- /ai/model-management (mock data only, no real backend)
- /ai/ocr-compare (old /vocab/ backend, replaced by ocr-kombi)
- /ai/ocr-pipeline (minimal session browser, redundant)
- /ai/ocr-overlay (legacy monolith, redundant)
- /ai/gpu (vast.ai GPU management, no longer used)
- /infrastructure/gpu (same)
- /communication/video-chat (moved to core)
- /communication/matrix (moved to core)

Deleted backends:
- backend-lehrer/infra/vast_client.py + vast_power.py
- backend-lehrer/meetings_api.py + jitsi_api.py
- website/app/api/admin/gpu/
- edu-search-service/scripts/vast_ai_extractor.py

Total: ~7,800 LOC removed. All code preserved in git history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:14:12 +02:00

329 lines
9.9 KiB
TypeScript

/**
* 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<number, number>()
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)
})
})