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/token-cache.test.ts (12272 bytes)

1 import 'fake-indexeddb/auto';
2 import { IDBFactory } from 'fake-indexeddb';
3 import fc from 'fast-check';
4 import { openDB } from 'idb';
5 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6 import type { BookSubtitles, PageEntry } from '../src/token-cache';
7 
8 const entry: PageEntry = {
9   tokens: [{ text: '猫', page: 0, line: 0, word: 0, bbox: { x0: 1, y0: 2, x1: 3, y1: 4 } }],
10   width: 1600,
11   height: 2263,
12   source: 'ocr',
13 };
14 
15 const srt = '1\n00:00:00,000 --> 00:00:02,500\n吾輩は猫である。\n';
16 
17 /** A new copy of the module. Its database connection is not open yet. */
18 async function freshCache() {
19   vi.resetModules();
20   return import('../src/token-cache');
21 }
22 
23 /** The version and the store names of the database. Open the cache first. */
24 async function schema(): Promise<{ version: number; stores: string[] }> {
25   const d = await openDB('scan-subread');
26   const result = { version: d.version, stores: [...d.objectStoreNames].sort() };
27   d.close();
28   return result;
29 }
30 
31 describe('token cache', () => {
32   beforeEach(() => {
33     // Each test gets an empty IndexedDB.
34     globalThis.indexedDB = new IDBFactory();
35   });
36   afterEach(() => vi.restoreAllMocks());
37 
38   it('upgrades version 1, which has the stores of the dictionary', async () => {
39     // Earlier builds made version 1 with the stores of the dictionary.
40     const old = await openDB('scan-subread', 1, {
41       upgrade(d) {
42         d.createObjectStore('dictionaries');
43         d.createObjectStore('terms');
44       },
45     });
46     await old.put('terms', { term: '猫' }, 'k');
47     old.close();
48 
49     const cache = await freshCache();
50     await cache.putPage('book.pdf|10|0|jpn|ocr', entry);
51     expect(await cache.getPage('book.pdf|10|0|jpn|ocr')).toEqual(entry);
52     expect(await schema()).toEqual({ version: 3, stores: ['books', 'meta', 'pages'] });
53   });
54 
55   it('upgrades version 2: the pages stay, and the subtitles move to the meta data', async () => {
56     const old = await openDB('scan-subread', 2, {
57       upgrade(d) {
58         d.createObjectStore('pages');
59         d.createObjectStore('srt');
60       },
61     });
62     await old.put('pages', entry, 'book.pdf|10|0|jpn|ocr');
63     await old.put('srt', srt, 'book.pdf|10');
64     await old.put('srt', 'x', 'Neko.PDF|20');
65     await old.put('srt', 'y', 'a|b|30');
66     old.close();
67 
68     const cache = await freshCache();
69     expect(await cache.getPage('book.pdf|10|0|jpn|ocr')).toEqual(entry);
70     expect(await cache.getMeta('book.pdf|10')).toEqual({
71       subtitles: { name: 'book.srt', text: srt, source: 'subread' },
72     });
73     expect(await cache.getMeta('Neko.PDF|20')).toEqual({
74       subtitles: { name: 'Neko.srt', text: 'x', source: 'subread' },
75     });
76     expect(await cache.getMeta('a|b|30')).toEqual({
77       subtitles: { name: 'a|b.srt', text: 'y', source: 'subread' },
78     });
79     expect(await schema()).toEqual({ version: 3, stores: ['books', 'meta', 'pages'] });
80   });
81 
82   it('makes the stores in a new database', async () => {
83     const cache = await freshCache();
84     expect(await cache.getPage('missing')).toBeUndefined();
85     expect(await cache.getMeta('missing')).toEqual({});
86     expect(await cache.getLastBook()).toBeUndefined();
87     expect(await schema()).toEqual({ version: 3, stores: ['books', 'meta', 'pages'] });
88   });
89 
90   it('keeps the last book, and a new book replaces it', async () => {
91     const cache = await freshCache();
92     const neko = new File(['%PDF-1.7 猫'], 'neko.pdf', { type: 'application/pdf' });
93     expect(await cache.putLastBook(neko)).toBe(true);
94     const back = await cache.getLastBook();
95     expect(back).toBeInstanceOf(File);
96     expect(back?.name).toBe('neko.pdf');
97     expect(back?.type).toBe('application/pdf');
98     expect(await back?.text()).toBe('%PDF-1.7 猫');
99     expect(back && cache.bookKey(back)).toBe(cache.bookKey(neko));
100 
101     const inu = new File(['%PDF-1.7 犬と猫'], 'inu.pdf', { type: 'application/pdf' });
102     expect(await cache.putLastBook(inu)).toBe(true);
103     expect((await cache.getLastBook())?.name).toBe('inu.pdf');
104 
105     const d = await openDB('scan-subread');
106     expect(await d.getAllKeys('books')).toEqual(['last']);
107     const record = await d.get('books', 'last');
108     d.close();
109     expect(record).toMatchObject({
110       key: cache.bookKey(inu),
111       name: 'inu.pdf',
112       type: 'application/pdf',
113     });
114     expect(record.blob).toBeInstanceOf(Blob);
115   });
116 
117   it('merges the meta data, and removes a field that is undefined in the patch', async () => {
118     const cache = await freshCache();
119     const subtitles: BookSubtitles = { name: 'neko.srt', text: srt, source: 'file' };
120     await cache.putMeta('neko.pdf|10', { page: 3 });
121     await cache.putMeta('neko.pdf|10', { subtitles, forceOcr: true });
122     expect(await cache.getMeta('neko.pdf|10')).toEqual({ page: 3, subtitles, forceOcr: true });
123 
124     await cache.putMeta('neko.pdf|10', { page: undefined, forceOcr: false });
125     const meta = await cache.getMeta('neko.pdf|10');
126     expect(meta).toEqual({ subtitles, forceOcr: false });
127     expect('page' in meta).toBe(false);
128     expect(await cache.getMeta('inu.pdf|20')).toEqual({});
129   });
130 
131   it('keeps a SubRead result for a book apart from its subtitles', async () => {
132     // The finding: a SubRead result for a book that was not open went into
133     // its subtitles, without the confirm and the checks of the language and
134     // the match rate. It now waits as a result, and the subtitles stay.
135     const cache = await freshCache();
136     const subtitles: BookSubtitles = { name: 'neko.srt', text: srt, source: 'file' };
137     const result = { srt: '1\n00:00:00,000 --> 00:00:01,000\nx\n', cues: 23, language: 'km' };
138     await cache.putMeta('neko.pdf|10', { subtitles });
139     await cache.putMeta('neko.pdf|10', { subread: { ...result, matchRate: 1 } });
140     expect(await cache.getMeta('neko.pdf|10')).toEqual({
141       subtitles,
142       subread: { ...result, matchRate: 1 },
143     });
144     // The offer of the result removes it.
145     await cache.putMeta('neko.pdf|10', { subread: undefined });
146     expect(await cache.getMeta('neko.pdf|10')).toEqual({ subtitles });
147   });
148 
149   it('keeps the fields of two merges at the same time', async () => {
150     const cache = await freshCache();
151     await Promise.all([
152       cache.putMeta('neko.pdf|10', { page: 1 }),
153       cache.putMeta('neko.pdf|10', { forceOcr: true }),
154     ]);
155     expect(await cache.getMeta('neko.pdf|10')).toEqual({ page: 1, forceOcr: true });
156   });
157 
158   it('forgets the last book, and keeps the meta data and the pages', async () => {
159     const cache = await freshCache();
160     const neko = new File(['%PDF-1.7 猫'], 'neko.pdf', { type: 'application/pdf' });
161     await cache.putLastBook(neko);
162     await cache.putMeta(cache.bookKey(neko), { page: 2 });
163     await cache.putPage(cache.pageKey(neko, 0, 'jpn', 'ocr'), entry);
164     await cache.clearLastBook(cache.bookKey(neko));
165     expect(await cache.getLastBook()).toBeUndefined();
166     expect(await cache.getMeta(cache.bookKey(neko))).toEqual({ page: 2 });
167     expect(await cache.getPage(cache.pageKey(neko, 0, 'jpn', 'ocr'))).toEqual(entry);
168     // No last book: nothing to forget.
169     await expect(cache.clearLastBook(cache.bookKey(neko))).resolves.toBeUndefined();
170   });
171 
172   it('forgets the last book only while the copy is of that book', async () => {
173     // The finding: the restore of the last book failed after the user had
174     // opened another book, and it deleted the copy of the user's book.
175     const cache = await freshCache();
176     const last = new File(['%PDF-1.7 猫'], 'neko.pdf', { type: 'application/pdf' });
177     const picked = new File(['%PDF-1.7 犬と猫'], 'inu.pdf', { type: 'application/pdf' });
178     await cache.putLastBook(last);
179     await cache.putLastBook(picked);
180     await cache.clearLastBook(cache.bookKey(last));
181     expect((await cache.getLastBook())?.name).toBe('inu.pdf');
182     await cache.clearLastBook(cache.bookKey(picked));
183     expect(await cache.getLastBook()).toBeUndefined();
184   });
185 
186   it('tells when the database is open, after the upgrade', async () => {
187     const old = await openDB('scan-subread', 1, {
188       upgrade(d) {
189         d.createObjectStore('dictionaries');
190         d.createObjectStore('terms');
191       },
192     });
193     old.close();
194     const cache = await freshCache();
195     await cache.dbReady();
196     expect(await schema()).toEqual({ version: 3, stores: ['books', 'meta', 'pages'] });
197   });
198 
199   it('clears the pages of one book, or the pages of all books', async () => {
200     const cache = await freshCache();
201     const a = { name: 'a.pdf', size: 1 };
202     // The key of this book, "a.pdf|10", starts with the key of `a`, "a.pdf|1".
203     const a10 = { name: 'a.pdf', size: 10 };
204     const keys = [
205       cache.pageKey(a, 0, 'jpn', 'ocr'),
206       cache.pageKey(a, 1, 'jpn', 'text'),
207       cache.pageKey(a10, 0, 'jpn', 'ocr'),
208     ] as const;
209     for (const key of keys) await cache.putPage(key, entry);
210     await cache.putMeta(cache.bookKey(a), { page: 1 });
211 
212     await cache.clearPages(cache.bookKey(a));
213     expect(await cache.getPage(keys[0])).toBeUndefined();
214     expect(await cache.getPage(keys[1])).toBeUndefined();
215     expect(await cache.getPage(keys[2])).toEqual(entry);
216     expect(await cache.getMeta(cache.bookKey(a))).toEqual({ page: 1 });
217 
218     await cache.clearPages();
219     expect(await cache.getPage(keys[2])).toBeUndefined();
220   });
221 
222   // 100 runs of IndexedDB work take about 2 s, and more than the 5 s default on a busy machine.
223   it('clears each page key that starts with the book key and "|", and no other key', async () => {
224     const cache = await freshCache();
225     const separator = fc.constantFrom('|', '||', '}', '{', '', '￿');
226     const tails = fc.array(fc.tuple(separator, fc.string({ unit: 'binary' })), { maxLength: 8 });
227     await fc.assert(
228       fc.asyncProperty(fc.string({ unit: 'binary' }), tails, async (book, parts) => {
229         await cache.clearPages();
230         const keys = parts.map(([sep, tail]) => `${book}${sep}${tail}`);
231         for (const key of keys) await cache.putPage(key, entry);
232         await cache.clearPages(book);
233         for (const key of keys) {
234           const cleared = key.startsWith(`${book}|`);
235           expect(await cache.getPage(key), key).toEqual(cleared ? undefined : entry);
236         }
237       }),
238     );
239   }, 30_000);
240 
241   it('gives back the file name of a book key, also a name with "|"', async () => {
242     const cache = await freshCache();
243     fc.assert(
244       fc.property(fc.string({ unit: 'binary' }), fc.nat(), (name, size) => {
245         expect(cache.bookName(cache.bookKey({ name, size }))).toBe(name);
246       }),
247     );
248   });
249 
250   it('works without IndexedDB, and warns once', async () => {
251     const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
252     vi.spyOn(indexedDB, 'open').mockImplementation(() => {
253       throw new DOMException('IndexedDB is off.', 'SecurityError');
254     });
255     const cache = await freshCache();
256     await expect(cache.getPage('p')).resolves.toBeUndefined();
257     await expect(cache.putPage('p', entry)).resolves.toBeUndefined();
258     await expect(cache.clearPages()).resolves.toBeUndefined();
259     await expect(cache.clearPages('b')).resolves.toBeUndefined();
260     await expect(cache.getMeta('b')).resolves.toEqual({});
261     await expect(cache.putMeta('b', { page: 1 })).resolves.toBeUndefined();
262     await expect(cache.putLastBook(new File(['x'], 'b.pdf'))).resolves.toBe(false);
263     await expect(cache.getLastBook()).resolves.toBeUndefined();
264     await expect(cache.clearLastBook('b.pdf|1')).resolves.toBeUndefined();
265     await expect(cache.dbReady()).resolves.toBeUndefined();
266     expect(warn).toHaveBeenCalledTimes(1);
267   });
268 
269   it('gives false when the book does not fit, and keeps the other data', async () => {
270     const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
271     const cache = await freshCache();
272     await cache.putPage('p', entry);
273     const put = vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => {
274       throw new DOMException('The quota is full.', 'QuotaExceededError');
275     });
276     await expect(cache.putLastBook(new File(['x'], 'b.pdf'))).resolves.toBe(false);
277     put.mockRestore();
278     expect(await cache.getPage('p')).toEqual(entry);
279     expect(await cache.getLastBook()).toBeUndefined();
280     expect(warn).toHaveBeenCalledTimes(1);
281   });
282 });