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>
329 lines
9.9 KiB
TypeScript
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)
|
|
})
|
|
})
|