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