Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


test/align.property.test.ts (13438 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import {
4   alignCuesToTokens,
5   createAligner,
6   normalizeText,
7   risingChain,
8   shiftSpans,
9   type TokenSpan,
10 } from '../src/align';
11 import { books, char, grams, pagesOfCue, readPages, word, type Book, type Token } from './book';
12 
13 /** One token for each character of `text`, all on `page`. */
14 function tokensOf(text: string, page = 0): Token[] {
15   return [...text].map((ch) => ({ text: ch, page }));
16 }
17 
18 /** Books without noise that are long enough for anchors. */
19 const exactBooks = books({ cues: [15, 60], cueLength: [5, 40], pageLength: [16, 200], noise: 0 });
20 
21 /** A book and a random choice of read pages. About one page in three is read. */
22 function withReadPages(arb: fc.Arbitrary<Book>): fc.Arbitrary<[Book, boolean[]]> {
23   return arb.chain((book) =>
24     fc.tuple(
25       fc.constant(book),
26       fc.array(fc.constantFrom(true, false, false), {
27         minLength: book.pages.length,
28         maxLength: book.pages.length,
29       }),
30     ),
31   );
32 }
33 
34 /**
35  * For a book without noise: the index of each character in the tokens of
36  * the read pages, joined in page order. -1 for a character on an unread page.
37  */
38 function tokenIndex(book: Book, read: readonly boolean[]): number[] {
39   let next = 0;
40   return book.pageOf.map((page) => (read[page] ? next++ : -1));
41 }
42 
43 /** The spans of the matched cues rise and stay inside [0, total]. */
44 function expectRising(spans: readonly TokenSpan[], total: number, disjoint: boolean): void {
45   let last: TokenSpan | null = null;
46   for (const span of spans) {
47     if (!span.matched) {
48       expect(span).toEqual({ start: 0, end: 0, matched: false });
49       continue;
50     }
51     expect(span.start).toBeGreaterThanOrEqual(0);
52     expect(span.end).toBeGreaterThan(span.start);
53     expect(span.end).toBeLessThanOrEqual(total);
54     if (last) {
55       expect(span.start).toBeGreaterThanOrEqual(disjoint ? last.end : last.start);
56       expect(span.end).toBeGreaterThanOrEqual(last.end);
57     }
58     last = span;
59   }
60 }
61 
62 describe('normalizeText', () => {
63   it('is idempotent', () => {
64     fc.assert(
65       fc.property(fc.string({ unit: 'grapheme' }), (s) => {
66         const once = normalizeText(s);
67         expect(normalizeText(once)).toBe(once);
68       }),
69     );
70   });
71 });
72 
73 describe('risingChain', () => {
74   /** The length of a longest strictly rising chain, by the slow O(n²) method. */
75   function slowLength(values: readonly number[]): number {
76     const best = values.map(() => 1);
77     for (let i = 0; i < values.length; i++) {
78       for (let j = 0; j < i; j++) {
79         if (values[j]! < values[i]!) best[i] = Math.max(best[i]!, best[j]! + 1);
80       }
81     }
82     return Math.max(0, ...best);
83   }
84 
85   it('gives a strictly rising chain as long as the slow reference', () => {
86     fc.assert(
87       fc.property(fc.array(fc.integer({ min: 0, max: 20 }), { maxLength: 40 }), (values) => {
88         const chain = risingChain(values);
89         for (let k = 1; k < chain.length; k++) {
90           expect(chain[k]!).toBeGreaterThan(chain[k - 1]!);
91           expect(values[chain[k]!]!).toBeGreaterThan(values[chain[k - 1]!]!);
92         }
93         expect(chain.length).toBe(slowLength(values));
94       }),
95     );
96   });
97 });
98 
99 describe('createAligner properties', () => {
100   it('maps exact text on all pages to exactly the tokens of each cue', () => {
101     fc.assert(
102       fc.property(exactBooks, (book) => {
103         const spans = createAligner(book.cues).spans(book.pages);
104         book.cues.forEach((_, i) => {
105           expect(spans[i]).toEqual({
106             start: book.offsets[i],
107             end: book.offsets[i + 1],
108             matched: true,
109           });
110         });
111       }),
112     );
113   });
114 
115   it('matches cues only on read pages, and each cue there to exactly its tokens', () => {
116     // The regression: with a few pages read, cues matched to wrong places.
117     fc.assert(
118       fc.property(withReadPages(exactBooks), ([book, read]) => {
119         const spans = createAligner(book.cues).spans(readPages(book, read));
120         const index = tokenIndex(book, read);
121         book.cues.forEach((_, i) => {
122           const tokens = index.slice(book.offsets[i], book.offsets[i + 1]).filter((t) => t >= 0);
123           if (tokens.length === 0) {
124             // The cue lies wholly on unread pages: no false positive.
125             expect(spans[i]!.matched).toBe(false);
126           } else {
127             expect(spans[i]).toEqual({
128               start: tokens[0],
129               end: tokens[tokens.length - 1]! + 1,
130               matched: true,
131             });
132           }
133         });
134       }),
135     );
136   });
137 
138   it('matches no cue when the cues share no 8-character run with the pages', () => {
139     // Cues from one random text, pages from another. Both use one alphabet,
140     // so chance runs of a few characters are common. The cue stream is
141     // longer than a short stream, which is compared with the pages in full.
142     fc.assert(
143       fc.property(
144         fc.array(word(15, 40), { minLength: 5, maxLength: 30 }),
145         exactBooks,
146         (cues, book) => {
147           const cueGrams = grams(cues.join(''));
148           fc.pre(
149             book.pages.every((page) =>
150               [...grams(page.map((t) => t.text).join(''))].every((g) => !cueGrams.has(g)),
151             ),
152           );
153           const spans = createAligner(cues).spans(book.pages);
154           expect(spans.filter((s) => s.matched)).toEqual([]);
155         },
156       ),
157     );
158   });
159 
160   it('gives the same spans with the page cache as a new aligner', () => {
161     fc.assert(
162       fc.property(withReadPages(exactBooks), ([book, read]) => {
163         const aligner = createAligner(book.cues);
164         aligner.spans(readPages(book, read));
165         // Read the other pages one by one, as the reader does.
166         const now = [...read];
167         for (let p = 0; p < now.length; p++) {
168           if (now[p]) continue;
169           now[p] = true;
170           const pages = readPages(book, now);
171           expect(aligner.spans(pages)).toEqual(createAligner(book.cues).spans(pages));
172         }
173       }),
174       { numRuns: 30 },
175     );
176   });
177 
178   it('gives rising, in-range spans when pages repeat, overlap or come out of order', () => {
179     const slices = (length: number) =>
180       fc.array(
181         fc.option(fc.record({ start: fc.nat({ max: length }), length: fc.nat({ max: 300 }) }), {
182           nil: undefined,
183         }),
184         { maxLength: 12 },
185       );
186     fc.assert(
187       fc.property(
188         exactBooks.chain((book) => fc.tuple(fc.constant(book), slices(book.pageOf.length))),
189         ([book, parts]) => {
190           const text = book.cues.join('');
191           const pages = parts.map(
192             (s, p) => s && tokensOf(text.slice(s.start, s.start + s.length), p),
193           );
194           const total = pages.reduce((n, p) => n + (p?.length ?? 0), 0);
195           // One character per token: the spans do not overlap.
196           expectRising(createAligner(book.cues).spans(pages), total, true);
197         },
198       ),
199     );
200   });
201 
202   it('gives rising, in-range spans for any text', () => {
203     fc.assert(
204       fc.property(
205         fc.array(fc.string({ unit: 'grapheme' }), { maxLength: 20 }),
206         fc.array(fc.option(fc.array(fc.string({ unit: 'grapheme' })), { nil: undefined }), {
207           maxLength: 5,
208         }),
209         (cues, texts) => {
210           const pages = texts.map((page, p) => page?.map((text) => ({ text, page: p })));
211           const total = pages.reduce((n, p) => n + (p?.length ?? 0), 0);
212           const spans = createAligner(cues).spans(pages);
213           expect(spans).toHaveLength(cues.length);
214           // A token can hold characters of two cues, so two spans can share a token.
215           expectRising(spans, total, false);
216         },
217       ),
218     );
219   });
220 
221   it('keeps 95% of the cues on read pages on their own pages under 5% noise', () => {
222     const noisy = books({
223       cues: [100, 150],
224       cueLength: [10, 40],
225       pageLength: [100, 400],
226       noise: 0.05,
227     });
228     fc.assert(
229       fc.property(withReadPages(noisy), ([book, read]) => {
230         const pages = readPages(book, read);
231         const tokens = pages.flatMap((p) => p ?? []);
232         const spans = createAligner(book.cues).spans(pages);
233         let onRead = 0;
234         let right = 0;
235         book.cues.forEach((_, i) => {
236           const own = pagesOfCue(book, i);
237           if (![...own].every((p) => read[p])) return;
238           onRead++;
239           const span = spans[i]!;
240           if (span.matched && tokens.slice(span.start, span.end).every((t) => own.has(t.page))) {
241             right++;
242           }
243         });
244         expect(right).toBeGreaterThanOrEqual(0.95 * onRead);
245       }),
246       { numRuns: 30 },
247     );
248   });
249 });
250 
251 describe('shiftSpans', () => {
252   const unmatched: TokenSpan = { start: 0, end: 0, matched: false };
253 
254   /** A span of `n` tokens: matched and inside [0, n], or unmatched. */
255   function span(n: number): fc.Arbitrary<TokenSpan> {
256     if (n === 0) return fc.constant(unmatched);
257     const matched = fc
258       .nat({ max: n - 1 })
259       .chain((start) =>
260         fc.integer({ min: start + 1, max: n }).map((end) => ({ start, end, matched: true })),
261       );
262     return fc.oneof(fc.constant(unmatched), matched);
263   }
264 
265   it('keeps the first and the last token of each span when tokens go in', () => {
266     // The finding: page 19, read after page 20, put its tokens in front of
267     // the tokens of page 20, and the spans pointed at other tokens until
268     // the next alignment.
269     const input = fc
270       .nat({ max: 40 })
271       .chain((n) =>
272         fc.tuple(fc.constant(n), fc.array(span(n)), fc.nat({ max: n }), fc.nat({ max: 20 })),
273       );
274     fc.assert(
275       fc.property(input, ([n, spans, at, count]) => {
276         const before = Array.from({ length: n }, (_, i) => `old ${i}`);
277         const after = [...before];
278         after.splice(at, 0, ...Array.from({ length: count }, (_, i) => `new ${i}`));
279         const shifted = shiftSpans(spans, at, count);
280         expect(shifted).toHaveLength(spans.length);
281         spans.forEach((s, i) => {
282           const moved = shifted[i]!;
283           if (!s.matched) {
284             expect(moved).toEqual(unmatched);
285             return;
286           }
287           expect(moved.matched).toBe(true);
288           expect(after[moved.start]).toBe(before[s.start]);
289           expect(after[moved.end - 1]).toBe(before[s.end - 1]);
290         });
291       }),
292     );
293   });
294 
295   it('moves the spans of page 20 behind the tokens of page 19', () => {
296     // Page 20 has 5 tokens and two cues. Page 19 puts 4 tokens in front.
297     const spans = [
298       { start: 0, end: 3, matched: true },
299       { start: 3, end: 5, matched: true },
300       unmatched,
301     ];
302     expect(shiftSpans(spans, 0, 4)).toEqual([
303       { start: 4, end: 7, matched: true },
304       { start: 7, end: 9, matched: true },
305       unmatched,
306     ]);
307   });
308 
309   it('keeps a span before the new tokens, and stretches a span across them', () => {
310     const spans = [
311       { start: 0, end: 2, matched: true },
312       { start: 2, end: 6, matched: true },
313     ];
314     expect(shiftSpans(spans, 4, 3)).toEqual([
315       { start: 0, end: 2, matched: true },
316       { start: 2, end: 9, matched: true },
317     ]);
318   });
319 });
320 
321 describe('alignCuesToTokens on one page', () => {
322   it('maps exact text to exact spans, for short cues too', () => {
323     fc.assert(
324       fc.property(fc.array(word(1, 30), { minLength: 1, maxLength: 20 }), (cues) => {
325         const spans = alignCuesToTokens(cues, tokensOf(cues.join('')));
326         let offset = 0;
327         cues.forEach((cue, i) => {
328           expect(spans[i]).toEqual({ start: offset, end: offset + cue.length, matched: true });
329           offset += cue.length;
330         });
331       }),
332     );
333   });
334 
335   it('keeps spans near the truth under modest random character noise', () => {
336     // One cue with up to one edit per ten characters.
337     const noisyCue = word(15, 40).chain((text) =>
338       fc.tuple(
339         fc.constant(text),
340         fc.array(
341           fc.record({
342             pos: fc.nat({ max: text.length - 1 }),
343             kind: fc.constantFrom('sub', 'del', 'ins'),
344             ch: char,
345           }),
346           { maxLength: Math.floor(text.length / 10) },
347         ),
348       ),
349     );
350 
351     fc.assert(
352       fc.property(fc.array(noisyCue, { minLength: 1, maxLength: 15 }), (items) => {
353         const cues = items.map(([text]) => text);
354         const noisy = items.map(([text, edits]) => applyEdits(text, edits));
355         const spans = alignCuesToTokens(cues, tokensOf(noisy.join('')));
356 
357         let offset = 0;
358         noisy.forEach((truth, i) => {
359           const trueStart = offset;
360           const trueEnd = offset + truth.length;
361           offset = trueEnd;
362           const s = spans[i]!;
363           const overlap = Math.max(0, Math.min(s.end, trueEnd) - Math.max(s.start, trueStart));
364           expect(overlap).toBeGreaterThanOrEqual(0.6 * truth.length);
365           expect(s.start).toBeGreaterThanOrEqual(trueStart - 4);
366           expect(s.end).toBeLessThanOrEqual(trueEnd + 4);
367         });
368       }),
369     );
370   });
371 });
372 
373 interface Edit {
374   pos: number;
375   kind: 'sub' | 'del' | 'ins';
376   ch: string;
377 }
378 
379 function applyEdits(text: string, edits: readonly Edit[]): string {
380   const chars = [...text];
381   // Apply from the end so earlier positions stay valid.
382   for (const e of [...edits].sort((x, y) => y.pos - x.pos)) {
383     if (e.kind === 'sub') chars[e.pos] = e.ch;
384     else if (e.kind === 'del') chars.splice(e.pos, 1);
385     else chars.splice(e.pos, 0, e.ch);
386   }
387   return chars.join('');
388 }