test/sentence.test.ts (6305 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import type { OcrToken } from '../src/align'; 4 import { SENTENCE_LENGTH, sentenceAround } from '../src/sentence'; 5 6 const box = { x0: 0, y0: 0, x1: 1, y1: 1 }; 7 8 /** Tokens for one page, one per character: `lines` is a list of lines, each a list of words. */ 9 function pageTokens(page: number, lines: readonly (readonly string[])[]): OcrToken[] { 10 const out: OcrToken[] = []; 11 lines.forEach((words, li) => { 12 words.forEach((word, wi) => { 13 for (const ch of word) { 14 out.push({ 15 text: ch, 16 page, 17 line: page * 1000 + li, 18 word: page * 1000 + li * 100 + wi, 19 bbox: box, 20 }); 21 } 22 }); 23 }); 24 return out; 25 } 26 27 /** The index of token number `n` (from 0) with the text `ch`. */ 28 function find(tokens: readonly OcrToken[], ch: string, n = 0): number { 29 let seen = 0; 30 for (let i = 0; i < tokens.length; i++) { 31 if (tokens[i]!.text !== ch) continue; 32 if (seen === n) return i; 33 seen++; 34 } 35 throw new Error(`No token ${ch} number ${n}.`); 36 } 37 38 describe('sentenceAround', () => { 39 // 吾輩は猫である。名前は / まだ無い。どこで生れたか 40 const jpn = pageTokens(0, [['吾輩は猫である。名前は'], ['まだ無い。どこで生れたか']]); 41 42 // It was a dark / night. The rain fell. 43 const eng = pageTokens(0, [ 44 ['It', 'was', 'a', 'dark'], 45 ['night.', 'The', 'rain', 'fell.'], 46 ]); 47 48 it('reads a Japanese sentence over two lines, with no space at the line break', () => { 49 expect(sentenceAround(jpn, find(jpn, '前'))).toBe('名前はまだ無い。'); 50 }); 51 52 it('reads an English sentence over two lines, with a space at the line break', () => { 53 expect(sentenceAround(eng, find(eng, 'k'))).toBe('It was a dark night.'); 54 expect(sentenceAround(eng, find(eng, 'f'))).toBe('The rain fell.'); 55 }); 56 57 it('gives the same sentence for a tap on its first and on its last character', () => { 58 expect(sentenceAround(jpn, find(jpn, '名'))).toBe('名前はまだ無い。'); 59 expect(sentenceAround(jpn, find(jpn, '。', 1))).toBe('名前はまだ無い。'); 60 expect(sentenceAround(jpn, find(jpn, '吾'))).toBe('吾輩は猫である。'); 61 expect(sentenceAround(jpn, find(jpn, '。', 0))).toBe('吾輩は猫である。'); 62 expect(sentenceAround(eng, find(eng, 'I'))).toBe('It was a dark night.'); 63 expect(sentenceAround(eng, find(eng, '.', 0))).toBe('It was a dark night.'); 64 expect(sentenceAround(eng, find(eng, 'T'))).toBe('The rain fell.'); 65 expect(sentenceAround(eng, find(eng, '.', 1))).toBe('The rain fell.'); 66 }); 67 68 it('runs to the edge of the page when no sentence end follows', () => { 69 expect(sentenceAround(jpn, find(jpn, 'ど'))).toBe('どこで生れたか'); 70 const open = pageTokens(0, [['吾輩は猫で'], ['ある']]); 71 expect(sentenceAround(open, find(open, '猫'))).toBe('吾輩は猫である'); 72 const words = pageTokens(0, [ 73 ['no', 'end', 'on'], 74 ['this', 'page'], 75 ]); 76 expect(sentenceAround(words, find(words, 'h'))).toBe('no end on this page'); 77 }); 78 79 it('stays on the page of the tapped token', () => { 80 const tokens = [...pageTokens(0, [['前の頁の文']]), ...pageTokens(1, [['次の頁の文。']])]; 81 expect(sentenceAround(tokens, find(tokens, '前'))).toBe('前の頁の文'); 82 expect(sentenceAround(tokens, find(tokens, '次'))).toBe('次の頁の文。'); 83 }); 84 85 it('keeps a closing bracket with the sentence that it ends', () => { 86 const quotes = pageTokens(0, [['「おはよう。」「はい。」']]); 87 expect(sentenceAround(quotes, find(quotes, 'よ'))).toBe('「おはよう。」'); 88 expect(sentenceAround(quotes, find(quotes, 'い'))).toBe('「はい。」'); 89 const english = pageTokens(0, [['"Go', 'away."', 'He', 'left.']]); 90 expect(sentenceAround(english, find(english, 'G'))).toBe('"Go away."'); 91 expect(sentenceAround(english, find(english, 'H'))).toBe('He left.'); 92 }); 93 94 it('does not end a sentence at a closing bracket with no end mark before it', () => { 95 const said = pageTokens(0, [['「はい」と言った。次']]); 96 expect(sentenceAround(said, find(said, 'と'))).toBe('「はい」と言った。'); 97 }); 98 99 it('does not end a sentence at a decimal point', () => { 100 const price = pageTokens(0, [['It', 'costs', '3.14', 'now.', 'Then', 'more.']]); 101 expect(sentenceAround(price, find(price, 'c'))).toBe('It costs 3.14 now.'); 102 }); 103 104 it('cuts a long sentence around the tapped token', () => { 105 const long = pageTokens(0, [['あ'.repeat(150) + '猫' + 'い'.repeat(150) + '。']]); 106 expect(sentenceAround(long, find(long, '猫'), 21)).toBe( 107 'あ'.repeat(10) + '猫' + 'い'.repeat(10), 108 ); 109 expect(sentenceAround(long, 0)).toBe('あ'.repeat(150) + '猫' + 'い'.repeat(49)); 110 expect([...sentenceAround(long, 301)].length).toBe(SENTENCE_LENGTH); 111 }); 112 113 it('returns an empty string for a token that does not exist', () => { 114 expect(sentenceAround(jpn, jpn.length)).toBe(''); 115 expect(sentenceAround([], 0)).toBe(''); 116 }); 117 118 it('always holds the tapped token and is at most max characters', () => { 119 const ch = fc.constantFrom('吾', 'は', 'a', 'Z', '7', '。', '!', '.', '?', '」', '"'); 120 const word = fc.array(ch, { minLength: 1, maxLength: 5 }).map((cs) => cs.join('')); 121 const line = fc.array(word, { minLength: 1, maxLength: 4 }); 122 const page = fc.array(line, { minLength: 1, maxLength: 4 }); 123 const book = fc.array(page, { minLength: 1, maxLength: 3 }); 124 fc.assert( 125 fc.property(book, fc.nat(), fc.integer({ min: 1, max: 40 }), (pages, pick, max) => { 126 const tokens = pages.flatMap((lines, p) => pageTokens(p, lines)); 127 const index = pick % tokens.length; 128 const tapped = tokens[index]!; 129 const sentence = sentenceAround(tokens, index, max); 130 expect(sentence).toContain(tapped.text); 131 expect([...sentence].length).toBeLessThanOrEqual(max); 132 // Without the spaces between words, the sentence is a piece of its page. 133 const pageChars = tokens 134 .filter((t) => t.page === tapped.page) 135 .map((t) => t.text) 136 .join(''); 137 expect(pageChars).toContain(sentence.replace(/ /g, '')); 138 }), 139 ); 140 }); 141 });