test/book-open.test.ts (10431 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import { 4 beginOpen, 5 createOpens, 6 endOpen, 7 failureShows, 8 lateMeta, 9 opening, 10 subtitlesOwner, 11 userOpened, 12 type Opens, 13 } from '../src/book-open'; 14 15 describe('lateMeta', () => { 16 const none = { page: false, forceOcr: false }; 17 18 it('does not undo "Force OCR" that the user set before the meta data came', () => { 19 // The finding: the user opens B and ticks Force OCR before the meta data 20 // of B comes. The meta data set Force OCR back, and the reading started 21 // again without it, while the store said on. 22 expect(lateMeta({}, { page: false, forceOcr: true })).toEqual({}); 23 expect(lateMeta({ forceOcr: false }, { page: false, forceOcr: true })).toEqual({}); 24 }); 25 26 it('does not go to the saved page after a page turn', () => { 27 expect(lateMeta({ page: 57 }, { page: true, forceOcr: false })).toEqual({ forceOcr: false }); 28 }); 29 30 it('applies each field that did not change', () => { 31 expect(lateMeta({ page: 57, forceOcr: true }, none)).toEqual({ page: 57, forceOcr: true }); 32 // Without a saved setting, Force OCR is off. Without a saved page, the page stays. 33 expect(lateMeta({}, none)).toEqual({ forceOcr: false }); 34 }); 35 36 it('gives exactly the fields that did not change, with their saved values', () => { 37 const meta = fc.record( 38 { page: fc.nat({ max: 500 }), forceOcr: fc.boolean() }, 39 { requiredKeys: [] }, 40 ); 41 const changes = fc.record({ page: fc.boolean(), forceOcr: fc.boolean() }); 42 fc.assert( 43 fc.property(meta, changes, (m, changed) => { 44 const late = lateMeta(m, changed); 45 expect('page' in late).toBe(!changed.page && m.page !== undefined); 46 if ('page' in late) expect(late.page).toBe(m.page); 47 expect('forceOcr' in late).toBe(!changed.forceOcr); 48 if ('forceOcr' in late) expect(late.forceOcr).toBe(m.forceOcr ?? false); 49 }), 50 ); 51 }); 52 }); 53 54 describe('failureShows', () => { 55 it('does not say the error of an older pick over the book that shows', () => { 56 // The finding: the user picks a large file A that is no PDF, then B. B 57 // shows first. Then A fails, and the strip said "This file is not a 58 // PDF, or it is damaged." over the good book B. 59 const o = createOpens(); 60 const a = beginOpen(o, true); 61 const b = beginOpen(o, true); 62 expect(endOpen(o, b, true)).toBe(true); 63 expect(endOpen(o, a, false)).toBe(false); 64 expect(failureShows(o, a)).toBe(false); 65 }); 66 67 it('says the error of a pick when no later pick shows', () => { 68 const o = createOpens(); 69 const a = beginOpen(o, true); 70 endOpen(o, a, false); 71 expect(failureShows(o, a)).toBe(true); 72 // B still loads when A fails. When B shows, its message replaces the error. 73 const c = beginOpen(o, true); 74 beginOpen(o, true); 75 endOpen(o, c, false); 76 expect(failureShows(o, c)).toBe(true); 77 }); 78 79 it('does not say the error of the restore after an open of the user', () => { 80 const o = createOpens(); 81 const since = o.byUser; 82 const l = beginOpen(o, false); 83 beginOpen(o, true); 84 endOpen(o, l, false); 85 // The book of the user still loads, and it shows its own message. 86 expect(failureShows(o, l, since)).toBe(false); 87 }); 88 89 it('says the error of the restore when the user opened nothing', () => { 90 const o = createOpens(); 91 const since = o.byUser; 92 const l = beginOpen(o, false); 93 endOpen(o, l, false); 94 expect(failureShows(o, l, since)).toBe(true); 95 }); 96 }); 97 98 /** One step of a script of opens: start the next attempt, or end one that loads. */ 99 interface Step { 100 begin: boolean; 101 /** The attempt that starts or ends. */ 102 attempt: number; 103 /** For a start: the user picked the file. */ 104 byUser: boolean; 105 /** For an end: the file loaded as a PDF. */ 106 loaded: boolean; 107 } 108 109 /** 110 * Scripts of up to 6 attempts. Each attempt starts in number order and ends 111 * after its start, in any order, with or without a loaded PDF. 112 */ 113 const scripts: fc.Arbitrary<Step[]> = fc 114 .record({ 115 count: fc.integer({ min: 1, max: 6 }), 116 byUser: fc.array(fc.boolean(), { minLength: 6, maxLength: 6 }), 117 loaded: fc.array(fc.boolean(), { minLength: 6, maxLength: 6 }), 118 picks: fc.array(fc.nat(), { minLength: 12, maxLength: 12 }), 119 }) 120 .map(({ count, byUser, loaded, picks }) => { 121 const steps: Step[] = []; 122 const loading: number[] = []; 123 let begun = 0; 124 const end = (attempt: number): Step => ({ 125 begin: false, 126 attempt, 127 byUser: false, 128 loaded: loaded[attempt - 1]!, 129 }); 130 for (const pick of picks) { 131 if (begun < count && (loading.length === 0 || pick % 2 === 0)) { 132 loading.push(++begun); 133 steps.push({ begin: true, attempt: begun, byUser: byUser[begun - 1]!, loaded: false }); 134 } else if (loading.length > 0) { 135 steps.push(end(loading.splice(pick % loading.length, 1)[0]!)); 136 } 137 } 138 // The attempts that still load end in their order. 139 for (const attempt of loading) steps.push(end(attempt)); 140 return steps; 141 }); 142 143 /** Runs one step of a script. */ 144 function run(o: Opens, step: Step): void { 145 if (step.begin) beginOpen(o, step.byUser); 146 else endOpen(o, step.attempt, step.loaded); 147 } 148 149 describe('open attempts', () => { 150 it('shows the book of the last attempt that loaded, whatever the order of the loads', () => { 151 fc.assert( 152 fc.property(scripts, (steps) => { 153 const o = createOpens(); 154 let lastLoaded = 0; 155 for (const step of steps) { 156 const before = o.shown; 157 if (step.begin) { 158 expect(beginOpen(o, step.byUser)).toBe(step.attempt); 159 } else { 160 const shows = endOpen(o, step.attempt, step.loaded); 161 // An attempt shows when it loaded and no later attempt shows. 162 expect(shows).toBe(step.loaded && step.attempt > before); 163 if (step.loaded) lastLoaded = Math.max(lastLoaded, step.attempt); 164 } 165 // The book that shows never goes back to an older attempt. 166 expect(o.shown).toBeGreaterThanOrEqual(before); 167 // An open loads that can replace the book that shows. 168 expect(opening(o)).toBe([...o.loading].some((a) => a > o.shown)); 169 } 170 expect(o.shown).toBe(lastLoaded); 171 expect(opening(o)).toBe(false); 172 }), 173 ); 174 }); 175 }); 176 177 /** Opens where book P shows, and the attempt of P. */ 178 function withP(): { o: Opens; p: number } { 179 const o = createOpens(); 180 const p = beginOpen(o, true); 181 endOpen(o, p, true); 182 return { o, p }; 183 } 184 185 describe('subtitlesOwner', () => { 186 it('gives subtitles that load during the open of another book to no book', () => { 187 // The finding: book P is open, the user picks a large PDF A, and loads 188 // the .srt of A before A shows. The .srt went into the meta data of P, 189 // over the subtitles of P, and the open of A then cleared it. 190 const { o } = withP(); 191 expect(subtitlesOwner(o, 'P')).toBe('P'); 192 const a = beginOpen(o, true); 193 expect(subtitlesOwner(o, 'P')).toBeNull(); 194 expect(endOpen(o, a, true)).toBe(true); 195 expect(subtitlesOwner(o, 'A')).toBe('A'); 196 }); 197 198 it('gives them to the book that stays open when the open fails', () => { 199 const { o } = withP(); 200 const a = beginOpen(o, true); 201 expect(subtitlesOwner(o, 'P')).toBeNull(); 202 expect(endOpen(o, a, false)).toBe(false); 203 expect(subtitlesOwner(o, 'P')).toBe('P'); 204 }); 205 206 it('does not wait for an older open that cannot show', () => { 207 // The user picks a large A, then a small B. B shows while A loads. 208 const o = createOpens(); 209 const a = beginOpen(o, true); 210 const b = beginOpen(o, true); 211 expect(endOpen(o, b, true)).toBe(true); 212 expect(subtitlesOwner(o, 'B')).toBe('B'); 213 expect(endOpen(o, a, true)).toBe(false); 214 }); 215 216 it('gives no book while no book is open', () => { 217 const o = createOpens(); 218 expect(subtitlesOwner(o, null)).toBeNull(); 219 beginOpen(o, true); 220 expect(subtitlesOwner(o, null)).toBeNull(); 221 }); 222 223 it('gives no book exactly while an open loads that can replace the book', () => { 224 fc.assert( 225 fc.property(scripts, (steps) => { 226 const o = createOpens(); 227 for (const step of steps) { 228 run(o, step); 229 expect(subtitlesOwner(o, 'book')).toBe(opening(o) ? null : 'book'); 230 } 231 }), 232 ); 233 }); 234 }); 235 236 describe('userOpened', () => { 237 it('keeps the copy of a book that the user opened while the last book loaded', () => { 238 // The finding: the restore opens a large last book L. The user picks a 239 // small B, B shows first, and the copy of B replaces L. The open of L 240 // then gives false, and the restore deleted the copy of B. 241 const o = createOpens(); 242 const since = o.byUser; 243 const l = beginOpen(o, false); 244 const b = beginOpen(o, true); 245 expect(endOpen(o, b, true)).toBe(true); 246 expect(endOpen(o, l, true)).toBe(false); 247 expect(userOpened(o, since)).toBe(true); 248 }); 249 250 it('does not open the last book over a book that the user picked while the database opened', () => { 251 // The finding: the database upgrade holds the restore, and the start 252 // card shows. The user picks B. When the database opens, B still loads 253 // and no book shows, so the restore opened the last book as a later 254 // attempt, and it won over B. 255 const o = createOpens(); 256 const since = o.byUser; 257 beginOpen(o, true); 258 expect(o.shown).toBe(0); 259 expect(userOpened(o, since)).toBe(true); 260 }); 261 262 it('lets the restore open and forget the last book when the user opened nothing', () => { 263 const o = createOpens(); 264 const since = o.byUser; 265 const l = beginOpen(o, false); 266 expect(userOpened(o, since)).toBe(false); 267 expect(endOpen(o, l, false)).toBe(false); 268 expect(userOpened(o, since)).toBe(false); 269 }); 270 271 it('is true exactly when the user started an open after the restore started', () => { 272 const input = scripts.chain((steps) => 273 fc.tuple(fc.constant(steps), fc.nat({ max: steps.length })), 274 ); 275 fc.assert( 276 fc.property(input, ([steps, start]) => { 277 const o = createOpens(); 278 steps.slice(0, start).forEach((step) => run(o, step)); 279 const since = o.byUser; 280 steps.slice(start).forEach((step) => run(o, step)); 281 const later = steps.slice(start).some((step) => step.begin && step.byUser); 282 expect(userOpened(o, since)).toBe(later); 283 }), 284 ); 285 }); 286 });