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/clock-source.test.ts (7792 bytes)

1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import {
3   JUMP_MS,
4   LocalAudioClock,
5   OverlayClock,
6   PlayerError,
7   POLL_ERROR_MS,
8   POLL_MS,
9   TICK_MS,
10   type ClockState,
11   type PlayerBridge,
12 } from '../src/clock-source';
13 
14 /** Expects `promise` to reject with a PlayerError for `reason`. */
15 async function expectPlayerError(promise: Promise<void>, reason: string): Promise<void> {
16   await expect(promise).rejects.toMatchObject({ name: 'PlayerError', reason, message: reason });
17   await expect(promise).rejects.toBeInstanceOf(PlayerError);
18 }
19 
20 /** A fake SubRead Overlay: answers the line set in `line`, counts the calls. */
21 function fakeBridge(line: string) {
22   const calls: string[] = [];
23   const bridge: PlayerBridge & { line: string; calls: string[] } = {
24     line,
25     calls,
26     playerState: () => {
27       calls.push('state');
28       return Promise.resolve({ line: bridge.line });
29     },
30     play: () => {
31       calls.push('play');
32       return Promise.resolve({ line: bridge.line });
33     },
34     pause: () => {
35       calls.push('pause');
36       return Promise.resolve({ line: bridge.line });
37     },
38     seek: ({ ms }) => {
39       calls.push(`seek ${ms}`);
40       return Promise.resolve({ line: bridge.line });
41     },
42   };
43   return bridge;
44 }
45 
46 describe('OverlayClock', () => {
47   beforeEach(() => vi.useFakeTimers());
48   afterEach(() => vi.useRealTimers());
49 
50   const now = () => Date.now();
51 
52   it('reads the player and computes the position between two reads', async () => {
53     const bridge = fakeBridge('playing=1;position=1000;speed=2;package=p');
54     const states: ClockState[] = [];
55     const clock = new OverlayClock(bridge, now);
56     clock.start((s) => states.push(s));
57     await vi.advanceTimersByTimeAsync(0);
58     expect(bridge.calls).toEqual(['state']);
59     expect(states.at(-1)).toEqual({ positionMs: 1000, playing: true, seeked: false, error: null });
60     await vi.advanceTimersByTimeAsync(TICK_MS);
61     expect(states.at(-1)!.positionMs).toBe(1000 + 2 * TICK_MS);
62     await vi.advanceTimersByTimeAsync(POLL_MS - TICK_MS);
63     expect(bridge.calls).toEqual(['state', 'state']);
64     clock.stop();
65   });
66 
67   it('marks a jump of the player as a seek', async () => {
68     const bridge = fakeBridge('playing=1;position=1000;speed=1;package=p');
69     const states: ClockState[] = [];
70     const clock = new OverlayClock(bridge, now);
71     clock.start((s) => states.push(s));
72     await vi.advanceTimersByTimeAsync(0);
73     bridge.line = `playing=1;position=${1000 + POLL_MS + JUMP_MS + 1};speed=1;package=p`;
74     await vi.advanceTimersByTimeAsync(POLL_MS);
75     expect(states.at(-1)!.seeked).toBe(true);
76     bridge.line = `playing=1;position=${1000 + 2 * POLL_MS + JUMP_MS + 1};speed=1;package=p`;
77     await vi.advanceTimersByTimeAsync(POLL_MS);
78     expect(states.at(-1)!.seeked).toBe(false);
79     clock.stop();
80   });
81 
82   it('reports an error, keeps the place, and reads less often', async () => {
83     const bridge = fakeBridge('playing=1;position=5000;speed=1;package=p');
84     const states: ClockState[] = [];
85     const clock = new OverlayClock(bridge, now);
86     clock.start((s) => states.push(s));
87     await vi.advanceTimersByTimeAsync(0);
88     bridge.line = 'error=no_player';
89     await vi.advanceTimersByTimeAsync(POLL_MS);
90     expect(states.at(-1)).toEqual({
91       positionMs: 5000 + POLL_MS,
92       playing: false,
93       seeked: false,
94       error: 'no_player',
95     });
96     const reads = bridge.calls.length;
97     await vi.advanceTimersByTimeAsync(POLL_MS);
98     expect(bridge.calls.length).toBe(reads);
99     await vi.advanceTimersByTimeAsync(POLL_ERROR_MS - POLL_MS);
100     expect(bridge.calls.length).toBe(reads + 1);
101     clock.stop();
102   });
103 
104   it('reports no overlay when the bridge fails', async () => {
105     const bridge = fakeBridge('');
106     bridge.playerState = () => Promise.reject(new Error('no plugin'));
107     const states: ClockState[] = [];
108     const clock = new OverlayClock(bridge, now);
109     clock.start((s) => states.push(s));
110     await vi.advanceTimersByTimeAsync(0);
111     expect(states.at(-1)).toEqual({
112       positionMs: null,
113       playing: false,
114       seeked: false,
115       error: 'no_overlay',
116     });
117     clock.stop();
118   });
119 
120   it('sets the clock at once on play, pause and seek, and skips one read', async () => {
121     const bridge = fakeBridge('playing=0;position=1000;speed=1;package=p');
122     const states: ClockState[] = [];
123     const clock = new OverlayClock(bridge, now);
124     clock.start((s) => states.push(s));
125     await vi.advanceTimersByTimeAsync(0);
126     await clock.play();
127     expect(bridge.calls).toEqual(['state', 'play']);
128     expect(states.at(-1)!.playing).toBe(true);
129     // The next read is skipped: the player takes a moment to start.
130     await vi.advanceTimersByTimeAsync(POLL_MS);
131     expect(bridge.calls).toEqual(['state', 'play']);
132     await vi.advanceTimersByTimeAsync(POLL_MS);
133     expect(bridge.calls).toEqual(['state', 'play', 'state']);
134 
135     await clock.seek(30000);
136     expect(bridge.calls.at(-1)).toBe('seek 30000');
137     expect(states.at(-1)).toMatchObject({ positionMs: 30000, seeked: true });
138 
139     await clock.pause();
140     expect(states.at(-1)!.playing).toBe(false);
141     clock.stop();
142   });
143 
144   it('rejects play, pause and seek when the player answers an error, and keeps the place', async () => {
145     const bridge = fakeBridge('playing=1;position=5000;speed=1;package=p');
146     const states: ClockState[] = [];
147     const clock = new OverlayClock(bridge, now);
148     clock.start((s) => states.push(s));
149     await vi.advanceTimersByTimeAsync(0);
150     bridge.line = 'error=no_player';
151     await expectPlayerError(clock.seek(17000), 'no_player');
152     expect(bridge.calls.at(-1)).toBe('seek 17000');
153     expect(states.at(-1)).toEqual({
154       positionMs: 5000,
155       playing: false,
156       seeked: false,
157       error: 'no_player',
158     });
159     await expectPlayerError(clock.play(), 'no_player');
160     expect(states.at(-1)!.playing).toBe(false);
161     await expectPlayerError(clock.pause(), 'no_player');
162     bridge.line = 'error=no_notification_access';
163     await expectPlayerError(clock.play(), 'no_notification_access');
164     clock.stop();
165   });
166 
167   it('rejects with no_overlay when the bridge fails', async () => {
168     const bridge = fakeBridge('playing=0;position=1000;speed=1;package=p');
169     bridge.play = () => Promise.reject(new Error('no plugin'));
170     const states: ClockState[] = [];
171     const clock = new OverlayClock(bridge, now);
172     clock.start((s) => states.push(s));
173     await vi.advanceTimersByTimeAsync(0);
174     await expectPlayerError(clock.play(), 'no_overlay');
175     expect(states.at(-1)!.error).toBe('no_overlay');
176     clock.stop();
177   });
178 });
179 
180 describe('LocalAudioClock', () => {
181   /** A fake audio element. Like a browser, it cannot play without a source. */
182   function fakeAudio() {
183     const audio = {
184       src: '',
185       currentTime: 0,
186       paused: true,
187       play: () =>
188         audio.src
189           ? Promise.resolve()
190           : Promise.reject(new DOMException('No supported sources.', 'NotSupportedError')),
191       pause: () => undefined,
192       addEventListener: () => undefined,
193       removeEventListener: () => undefined,
194     };
195     return audio;
196   }
197 
198   it('rejects play and seek with no_player when no audio is loaded', async () => {
199     const audio = fakeAudio();
200     const clock = new LocalAudioClock(audio as unknown as HTMLAudioElement);
201     await expectPlayerError(clock.play(), 'no_player');
202     await expectPlayerError(clock.seek(17000), 'no_player');
203     expect(audio.currentTime).toBe(0);
204   });
205 
206   it('plays and seeks when audio is loaded', async () => {
207     const audio = fakeAudio();
208     audio.src = 'blob:audio';
209     const clock = new LocalAudioClock(audio as unknown as HTMLAudioElement);
210     await clock.play();
211     await clock.seek(17000);
212     expect(audio.currentTime).toBe(17);
213   });
214 });