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 });