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