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/follower.test.ts (6783 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import { audioPage, type CueParts } from '../src/cue-pages';
4 import { follow, type FollowEvent, type FollowInput, type FollowMode } from '../src/follower';
5 
6 /** The page of each cue. Null for an unmatched cue. */
7 const pages: (number | null)[] = [0, 0, null, 1, 1, null, null, 2];
8 const parts: CueParts[] = pages.map((p) => (p === null ? [] : [{ page: p, share: 1 }]));
9 
10 function input(over: Partial<FollowInput>): FollowInput {
11   return {
12     mode: 'highlight',
13     currentPage: 0,
14     cue: 0,
15     matched: true,
16     page: 0,
17     held: false,
18     event: 'tick',
19     ...over,
20   };
21 }
22 
23 /** The input of the follow at the start of cue `cue` of `pages`. */
24 function at(cue: number, over: Partial<FollowInput> = {}): FollowInput {
25   return input({
26     cue,
27     matched: pages[cue] !== null && pages[cue] !== undefined,
28     page: audioPage(parts, cue, 0),
29     ...over,
30   });
31 }
32 
33 describe('follow', () => {
34   it('marks a matched cue and stays on its page', () => {
35     expect(follow(at(1))).toEqual({ highlightCue: 1, turnToPage: null, held: false });
36   });
37 
38   it('turns to the page of a matched cue', () => {
39     expect(follow(at(3))).toEqual({ highlightCue: 3, turnToPage: 1, held: false });
40   });
41 
42   it('turns for an unmatched cue with a matched cue before it, without a mark', () => {
43     expect(follow(at(5, { currentPage: 0 }))).toEqual({
44       highlightCue: null,
45       turnToPage: 1,
46       held: false,
47     });
48     expect(follow(at(5, { currentPage: 1 }))).toEqual({
49       highlightCue: null,
50       turnToPage: null,
51       held: false,
52     });
53   });
54 
55   it('does nothing for a cue that has no page', () => {
56     expect(follow(input({ cue: 9, matched: false, page: null, currentPage: 5 }))).toEqual({
57       highlightCue: null,
58       turnToPage: null,
59       held: false,
60     });
61   });
62 
63   it('only turns pages in pages mode', () => {
64     expect(follow(at(3, { mode: 'pages' }))).toEqual({
65       highlightCue: null,
66       turnToPage: 1,
67       held: false,
68     });
69   });
70 
71   it('does nothing when off, and before the first cue', () => {
72     expect(follow(at(3, { mode: 'off' }))).toEqual({
73       highlightCue: null,
74       turnToPage: null,
75       held: false,
76     });
77     expect(follow(input({ cue: -1, page: null }))).toEqual({
78       highlightCue: null,
79       turnToPage: null,
80       held: false,
81     });
82   });
83 
84   it('turns again inside the same cue when the page of the audio changes', () => {
85     // A cue on two pages: the page follows the time, so one cue gives two pages.
86     expect(follow(input({ cue: 7, page: 0, currentPage: 0 })).turnToPage).toBeNull();
87     expect(follow(input({ cue: 7, page: 1, currentPage: 0 })).turnToPage).toBe(1);
88   });
89 
90   it('keeps a page that the user turned to while the audio plays on', () => {
91     // The audio plays cue 1 on page 0. At 4.43 s the user turns to page 1.
92     // The next cue, on page 0, must not turn the page back.
93     let held = true;
94     let out = follow(at(1, { currentPage: 1, held }));
95     expect(out).toEqual({ highlightCue: 1, turnToPage: null, held: true });
96     held = out.held;
97     out = follow(at(2, { currentPage: 1, held }));
98     expect(out).toEqual({ highlightCue: null, turnToPage: null, held: true });
99     // A page that is read and aligned again does not end the hold either.
100     out = follow(at(2, { currentPage: 1, held, event: 'realign' }));
101     expect(out.held).toBe(true);
102     expect(out.turnToPage).toBeNull();
103     // The audio reaches page 1: the hold ends, and the cue is marked.
104     out = follow(at(3, { currentPage: 1, held }));
105     expect(out).toEqual({ highlightCue: 3, turnToPage: null, held: false });
106     // From here the follow turns the page again.
107     expect(follow(at(7, { currentPage: 1, held: out.held })).turnToPage).toBe(2);
108   });
109 
110   it('ends the hold on a seek and on the Follow button, and turns at once', () => {
111     for (const event of ['seek', 'follow'] as const) {
112       expect(follow(at(3, { currentPage: 0, held: true, event }))).toEqual({
113         highlightCue: 3,
114         turnToPage: 1,
115         held: false,
116       });
117     }
118   });
119 
120   const mode = fc.constantFrom<FollowMode>('highlight', 'pages', 'off');
121   const event = fc.constantFrom<FollowEvent>('tick', 'realign', 'seek', 'follow');
122   const any = fc.record({
123     mode,
124     currentPage: fc.nat({ max: 5 }),
125     cue: fc.integer({ min: -1, max: 30 }),
126     matched: fc.boolean(),
127     page: fc.option(fc.nat({ max: 5 }), { nil: null }),
128     held: fc.boolean(),
129     event,
130   });
131 
132   it('never turns a page while held, without a seek or the Follow button', () => {
133     fc.assert(
134       fc.property(any, fc.constantFrom<FollowEvent>('tick', 'realign'), (i, quiet) => {
135         const out = follow({ ...i, held: true, event: quiet });
136         if (out.held) expect(out.turnToPage).toBeNull();
137         // A held follow that ends without a seek ends because the audio is on the page shown.
138         if (!out.held) expect(i.page).toBe(i.currentPage);
139         expect(out.turnToPage).toBeNull();
140       }),
141     );
142   });
143 
144   it('ends the hold when the audio is on the page shown', () => {
145     fc.assert(
146       fc.property(any, (i) => {
147         const out = follow({ ...i, page: i.currentPage });
148         if (i.mode !== 'off' && i.cue >= 0) expect(out.held).toBe(false);
149         expect(out.turnToPage).toBeNull();
150       }),
151     );
152   });
153 
154   it('ends the hold on a seek and on the Follow button', () => {
155     fc.assert(
156       fc.property(any, fc.constantFrom<FollowEvent>('seek', 'follow'), (i, loud) => {
157         expect(follow({ ...i, event: loud }).held).toBe(false);
158       }),
159     );
160   });
161 
162   it('shows the page of the audio when the follow is not held', () => {
163     fc.assert(
164       fc.property(any, (i) => {
165         const out = follow(i);
166         if (out.turnToPage !== null) {
167           expect(out.turnToPage).toBe(i.page);
168           expect(out.turnToPage).not.toBe(i.currentPage);
169         }
170         if (!out.held && i.mode !== 'off' && i.cue >= 0 && i.page !== null) {
171           expect(out.turnToPage ?? i.currentPage).toBe(i.page);
172         }
173       }),
174     );
175   });
176 
177   it('marks only the cue of now, and only when it is matched', () => {
178     fc.assert(
179       fc.property(any, (i) => {
180         const out = follow(i);
181         if (out.highlightCue !== null) {
182           expect(out.highlightCue).toBe(i.cue);
183           expect(i.mode).toBe('highlight');
184           expect(i.matched).toBe(true);
185         }
186       }),
187     );
188   });
189 
190   it('never acts when off, and never marks in pages mode', () => {
191     fc.assert(
192       fc.property(any, (i) => {
193         const out = follow({ ...i, mode: 'off' });
194         expect(out.highlightCue).toBeNull();
195         expect(out.turnToPage).toBeNull();
196         expect(follow({ ...i, mode: 'pages' }).highlightCue).toBeNull();
197       }),
198     );
199   });
200 });