test/align.test.ts (10108 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import { alignCuesToTokens, buildStream, createAligner, normalizeText } from '../src/align'; 4 import { ENG2 } from '../fixtures/sample-text'; 5 import { books, char, grams, pagesOfCue, readPages, type Token } from './book'; 6 7 /** One token for each character of `text`, all on `page`. */ 8 function tokensOf(text: string, page = 0): Token[] { 9 return [...text].map((ch) => ({ text: ch, page })); 10 } 11 12 /** The opening of A Tale of Two Cities, as cues. */ 13 const TWO_CITIES = [ 14 'It was the best of times,', 15 'it was the worst of times,', 16 'it was the age of wisdom,', 17 'it was the age of foolishness,', 18 'it was the epoch of belief,', 19 'it was the epoch of incredulity,', 20 'it was the season of Light,', 21 'it was the season of Darkness,', 22 ]; 23 24 describe('normalizeText', () => { 25 it('drops spaces and punctuation and folds case', () => { 26 expect(normalizeText('Hello, World! ')).toBe('helloworld'); 27 }); 28 29 it('folds full-width forms with NFKC', () => { 30 expect(normalizeText('ABC123')).toBe('abc123'); 31 }); 32 33 it('keeps kana, kanji and the long vowel mark', () => { 34 expect(normalizeText('コーヒーを飲む。')).toBe('コーヒーを飲む'); 35 }); 36 37 it('gives a stable result when case folding moves combining marks', () => { 38 // fast-check found these inputs. The case map and the filter left the 39 // combining marks out of canonical order, so a second pass changed the text. 40 for (const s of ['\u{1D157}\u{1D165}\u{FC5E}', '\u{130}\u{1D15E}']) { 41 const once = normalizeText(s); 42 expect(normalizeText(once)).toBe(once); 43 } 44 }); 45 }); 46 47 describe('alignCuesToTokens on one page', () => { 48 it('maps exact text to exact spans', () => { 49 const cues = ['thequick', 'brownfox', 'jumps']; 50 expect(alignCuesToTokens(cues, tokensOf(cues.join('')))).toEqual([ 51 { start: 0, end: 8, matched: true }, 52 { start: 8, end: 16, matched: true }, 53 { start: 16, end: 21, matched: true }, 54 ]); 55 }); 56 57 it('tolerates OCR substitutions and drops', () => { 58 // "rn" read as "m", "l" read as "1", one char lost. 59 const cues = ['themorningsun', 'wasclearandbright']; 60 const spans = alignCuesToTokens(cues, tokensOf('thernorningsun' + 'wasc1earandbrigt')); 61 expect(spans[0]).toEqual({ start: 0, end: 14, matched: true }); 62 expect(spans[1]).toEqual({ start: 14, end: 30, matched: true }); 63 }); 64 65 it('skips OCR text that no cue covers, such as a page header', () => { 66 const cues = ['firstline', 'secondline']; 67 const spans = alignCuesToTokens( 68 cues, 69 tokensOf('chapterone' + 'firstline' + 'pagenumber12' + 'secondline'), 70 ); 71 expect(spans[0]).toEqual({ start: 10, end: 19, matched: true }); 72 expect(spans[1]).toEqual({ start: 31, end: 41, matched: true }); 73 }); 74 75 it('leaves a cue that is not on the page unmatched, with an empty span', () => { 76 const cues = ['abcdefgh', 'zzzzzzzz', 'ijklmnop']; 77 const spans = alignCuesToTokens(cues, tokensOf('abcdefgh' + 'qqqqqqqq' + 'ijklmnop')); 78 expect(spans).toEqual([ 79 { start: 0, end: 8, matched: true }, 80 { start: 0, end: 0, matched: false }, 81 { start: 16, end: 24, matched: true }, 82 ]); 83 }); 84 85 it('aligns Japanese text at character level with OCR noise', () => { 86 const cues = ['吾輩は猫である', '名前はまだ無い', 'どこで生れたか']; 87 // 猫 read as 描, one char doubled, one char lost. 88 const spans = alignCuesToTokens( 89 cues, 90 tokensOf('吾輩は描である' + '名前はまだだ無い' + 'どこで生れか'), 91 ); 92 expect(spans[0]).toEqual({ start: 0, end: 7, matched: true }); 93 expect(spans[1]).toEqual({ start: 7, end: 15, matched: true }); 94 expect(spans[2]).toEqual({ start: 15, end: 21, matched: true }); 95 }); 96 97 it('returns empty spans for empty input', () => { 98 expect(alignCuesToTokens([], tokensOf('abc'))).toEqual([]); 99 expect(alignCuesToTokens(['abc'], [])).toEqual([{ start: 0, end: 0, matched: false }]); 100 }); 101 102 it('runs the full pipeline on word tokens with punctuation tokens', () => { 103 const tokens = ['It', 'was', 'a', 'dark', 'and', 'stormy', 'night', '.', 'The', 'rain'].map( 104 (text) => ({ text, page: 0 }), 105 ); 106 const spans = alignCuesToTokens(['It was a dark', 'and stormy night.', 'The rain'], tokens); 107 expect(spans).toEqual([ 108 { start: 0, end: 4, matched: true }, 109 { start: 4, end: 7, matched: true }, 110 { start: 8, end: 10, matched: true }, 111 ]); 112 }); 113 114 it('works with one token per character, as for vertical Japanese', () => { 115 const spans = alignCuesToTokens( 116 ['春はあけぼの', 'やうやう白くなりゆく山ぎは'], 117 tokensOf('春はあけぼの。やうやう白くなりゆく山ぎは'), 118 ); 119 expect(spans[0]).toEqual({ start: 0, end: 6, matched: true }); 120 expect(spans[1]).toEqual({ start: 7, end: 20, matched: true }); 121 }); 122 123 it('refuses tokens that are not in page order', () => { 124 const tokens = [ 125 { text: 'a', page: 1 }, 126 { text: 'b', page: 0 }, 127 ]; 128 expect(() => alignCuesToTokens(['ab'], tokens)).toThrow(/page order/); 129 }); 130 }); 131 132 describe('buildStream', () => { 133 it('maps chars back to the tokens that hold them', () => { 134 const stream = buildStream(['The', 'quick,', '—', 'fox']); 135 expect(stream.chars).toBe('thequickfox'); 136 expect(stream.tokenOf).toEqual([0, 0, 0, 1, 1, 1, 1, 1, 3, 3, 3]); 137 }); 138 }); 139 140 describe('createAligner', () => { 141 const text = normalizeText(TWO_CITIES.join('')); 142 const cues = text.match(/.{1,20}/g)!; 143 144 it('indexes the tokens of the read pages, joined in page order', () => { 145 const pages = [tokensOf(text.slice(0, 60), 0), undefined, tokensOf(text.slice(60), 2)]; 146 const spans = createAligner(cues).spans(pages); 147 expect(spans.map((s) => s.matched)).toEqual(cues.map(() => true)); 148 expect(spans[0]).toEqual({ start: 0, end: 20, matched: true }); 149 expect(spans[3]).toEqual({ start: 60, end: 80, matched: true }); 150 }); 151 152 it('leaves the cues of an unread page unmatched', () => { 153 const pages = [undefined, undefined, tokensOf(text.slice(60), 2)]; 154 const spans = createAligner(cues).spans(pages); 155 expect(spans.slice(0, 3).map((s) => s.matched)).toEqual([false, false, false]); 156 expect(spans[3]).toEqual({ start: 0, end: 20, matched: true }); 157 }); 158 159 it('matches no cue of subtitles for another text (regression)', () => { 160 // The opening of A Tale of Two Cities over the two pages of the 161 // sample-eng-2p fixture. The texts share no 8-character run, so no 162 // page has an anchor. One diff of all cues matched every cue on chance 163 // runs of two characters. 164 const pages = ENG2.pages.map((lines, p) => tokensOf(lines.join(' '), p)); 165 const pageGrams = grams(ENG2.pages.flat().join('')); 166 expect([...grams(TWO_CITIES.join(''))].filter((g) => pageGrams.has(g))).toEqual([]); 167 168 const spans = alignCuesToTokens(TWO_CITIES, pages.flat()); 169 expect(spans.filter((s) => s.matched)).toEqual([]); 170 }); 171 172 it('matches no cue to a wrong page when only 3 pages in the middle are read (regression)', () => { 173 // The measured defect in small form: a book of 20 pages with 5% OCR 174 // noise, and only pages 9 to 11 read. One diff of all cues against 175 // those pages matched cues to wrong places. 176 const [book] = fc.sample( 177 books({ cues: [220, 220], cueLength: [15, 40], pageLength: [300, 300], noise: 0.05 }), 178 { seed: 3, numRuns: 1 }, 179 ); 180 const read = book!.pages.map((_, p) => p >= 9 && p <= 11); 181 const pages = readPages(book!, read); 182 const tokens = pages.flatMap((p) => p ?? []); 183 const spans = alignCuesToTokens(book!.cues, tokens); 184 185 const wrong: number[] = []; 186 const missed: number[] = []; 187 book!.cues.forEach((_, i) => { 188 const own = pagesOfCue(book!, i); 189 const span = spans[i]!; 190 const onOwnPages = tokens.slice(span.start, span.end).every((t) => own.has(t.page)); 191 if (span.matched && !onOwnPages) wrong.push(i); 192 if ([...own].every((p) => read[p]) && !(span.matched && onOwnPages)) missed.push(i); 193 }); 194 expect(wrong).toEqual([]); 195 expect(missed).toEqual([]); 196 }); 197 }); 198 199 // The bounds leave room for a busy machine: the aligner takes about 1 s and 200 // 30 ms here, and took 4.3 s once under heavy load. The old global diff took 201 // 13 s for the full book, and the same again for each page, so the test still 202 // catches a return to it. 203 describe('createAligner speed', () => { 204 it('aligns a book of 300 000 characters in under 8 s, and one more page in under 500 ms', () => { 205 const LENGTH = 300_000; 206 const PAGE = 600; 207 // 5% noise: 3 codes in 60 are a drop, a substitution and an insertion. 208 const codes = [...'k'.repeat(57), 'd', 's', 'i']; 209 const [sample] = fc.sample( 210 fc.tuple( 211 fc.string({ unit: char, minLength: LENGTH, maxLength: LENGTH }), 212 fc.string({ unit: fc.constantFrom(...codes), minLength: LENGTH, maxLength: LENGTH }), 213 ), 214 { seed: 1, numRuns: 1 }, 215 ); 216 const [text, noise] = sample!; 217 const cues = text.match(/.{1,40}/g)!; 218 const pages: Token[][] = []; 219 for (let i = 0; i < LENGTH; i++) { 220 const page = Math.floor(i / PAGE); 221 const tokens = (pages[page] ??= []); 222 const ch = text[i]!; 223 const other = text[(i + 1) % LENGTH]!; 224 if (noise[i] === 's') tokens.push({ text: other, page }); 225 else if (noise[i] === 'i') tokens.push({ text: other, page }, { text: ch, page }); 226 else if (noise[i] !== 'd') tokens.push({ text: ch, page }); 227 } 228 229 let t = performance.now(); 230 const aligner = createAligner(cues); 231 const spans = aligner.spans(pages); 232 const all = performance.now() - t; 233 // Read one page again: a new token array, as after a change of the OCR language. 234 pages[250] = [...pages[250]!]; 235 t = performance.now(); 236 aligner.spans(pages); 237 const one = performance.now() - t; 238 239 expect(spans.filter((s) => s.matched).length).toBeGreaterThanOrEqual(0.99 * cues.length); 240 expect(all).toBeLessThan(8000); 241 expect(one).toBeLessThan(500); 242 }, 30_000); 243 });