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