Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2020-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {IDBFactory, IDBKeyRange} from 'fake-indexeddb';
20import {readFileSync} from 'node:fs';
21import {fileURLToPath} from 'node:url';
22import {join, dirname as pathDirname} from 'path';
23import {beforeEach, describe, test, vi} from 'vitest';
24import {createDictionaryArchiveData, getDictionaryArchiveIndex} from '../dev/dictionary-archive-util.js';
25import {parseJson} from '../dev/json.js';
26import {DictionaryDatabase} from '../ext/js/dictionary/dictionary-database.js';
27import {DictionaryImporter} from '../ext/js/dictionary/dictionary-importer.js';
28import {DictionaryImporterMediaLoader} from './mocks/dictionary-importer-media-loader.js';
29import {setupStubs} from './utilities/database.js';
30
31const dirname = pathDirname(fileURLToPath(import.meta.url));
32
33setupStubs();
34vi.stubGlobal('IDBKeyRange', IDBKeyRange);
35
36/**
37 * @param {string} dictionary
38 * @param {string} [dictionaryName]
39 * @returns {Promise<ArrayBuffer>}
40 */
41async function createTestDictionaryArchiveData(dictionary, dictionaryName) {
42 const dictionaryDirectory = join(dirname, 'data', 'dictionaries', dictionary);
43 return await createDictionaryArchiveData(dictionaryDirectory, dictionaryName);
44}
45
46/**
47 * @param {import('vitest').ExpectStatic} expect
48 * @param {import('dictionary-importer').OnProgressCallback} [onProgress]
49 * @returns {DictionaryImporter}
50 */
51function createDictionaryImporter(expect, onProgress) {
52 const dictionaryImporterMediaLoader = new DictionaryImporterMediaLoader();
53 return new DictionaryImporter(dictionaryImporterMediaLoader, (...args) => {
54 const {index, count} = args[0];
55 expect.soft(index <= count).toBe(true);
56 if (typeof onProgress === 'function') {
57 onProgress(...args);
58 }
59 });
60}
61
62/**
63 * @param {import('dictionary-database').TermEntry[]} dictionaryDatabaseEntries
64 * @param {string} term
65 * @returns {number}
66 */
67function countDictionaryDatabaseEntriesWithTerm(dictionaryDatabaseEntries, term) {
68 return dictionaryDatabaseEntries.reduce((i, v) => (i + (v.term === term ? 1 : 0)), 0);
69}
70
71/**
72 * @param {import('dictionary-database').TermEntry[]} dictionaryDatabaseEntries
73 * @param {string} reading
74 * @returns {number}
75 */
76function countDictionaryDatabaseEntriesWithReading(dictionaryDatabaseEntries, reading) {
77 return dictionaryDatabaseEntries.reduce((i, v) => (i + (v.reading === reading ? 1 : 0)), 0);
78}
79
80/**
81 * @param {import('dictionary-database').TermMeta[]|import('dictionary-database').KanjiMeta[]} metas
82 * @param {import('dictionary-database').TermMetaType|import('dictionary-database').KanjiMetaType} mode
83 * @returns {number}
84 */
85function countMetasWithMode(metas, mode) {
86 let i = 0;
87 for (const item of metas) {
88 if (item.mode === mode) { ++i; }
89 }
90 return i;
91}
92
93/**
94 * @param {import('dictionary-database').KanjiEntry[]} kanji
95 * @param {string} character
96 * @returns {number}
97 */
98function countKanjiWithCharacter(kanji, character) {
99 let i = 0;
100 for (const item of kanji) {
101 if (item.character === character) { ++i; }
102 }
103 return i;
104}
105
106
107/** */
108describe('Database', () => {
109 beforeEach(async () => {
110 globalThis.indexedDB = new IDBFactory();
111 });
112 test('Database invalid usage', async ({expect}) => {
113 // Load dictionary data
114 const testDictionarySource = await createTestDictionaryArchiveData('valid-dictionary1');
115 const testDictionaryIndex = await getDictionaryArchiveIndex(testDictionarySource);
116
117 const title = testDictionaryIndex.title;
118 const titles = new Map([
119 [title, {alias: title, allowSecondarySearches: false}],
120 ]);
121
122 // Setup database
123 const dictionaryDatabase = new DictionaryDatabase();
124 /** @type {import('dictionary-importer').ImportDetails} */
125 const defaultImportDetails = {prefixWildcardsSupported: false, yomitanVersion: '0.0.0.0'};
126
127 // Database not open
128 await expect.soft(dictionaryDatabase.deleteDictionary(title, 1000, () => {})).rejects.toThrow('Database not open');
129 await expect.soft(dictionaryDatabase.findTermsBulk(['?'], titles, 'exact')).rejects.toThrow('Database not open');
130 await expect.soft(dictionaryDatabase.findTermsExactBulk([{term: '?', reading: '?'}], titles)).rejects.toThrow('Database not open');
131 await expect.soft(dictionaryDatabase.findTermsBySequenceBulk([{query: 1, dictionary: title}])).rejects.toThrow('Database not open');
132 await expect.soft(dictionaryDatabase.findTermMetaBulk(['?'], titles)).rejects.toThrow('Database not open');
133 await expect.soft(dictionaryDatabase.findTermMetaBulk(['?'], titles)).rejects.toThrow('Database not open');
134 await expect.soft(dictionaryDatabase.findKanjiBulk(['?'], titles)).rejects.toThrow('Database not open');
135 await expect.soft(dictionaryDatabase.findKanjiMetaBulk(['?'], titles)).rejects.toThrow('Database not open');
136 await expect.soft(dictionaryDatabase.findTagForTitle('tag', title)).rejects.toThrow('Database not open');
137 await expect.soft(dictionaryDatabase.getDictionaryInfo()).rejects.toThrow('Database not open');
138 await expect.soft(dictionaryDatabase.getDictionaryCounts([...titles.keys()], true)).rejects.toThrow('Database not open');
139 await expect.soft(createDictionaryImporter(expect).importDictionary(dictionaryDatabase, testDictionarySource, defaultImportDetails)).rejects.toThrow('Database is not ready');
140
141 await dictionaryDatabase.prepare();
142
143 // Already prepared
144 await expect.soft(dictionaryDatabase.prepare()).rejects.toThrow('Database already open');
145
146 await createDictionaryImporter(expect).importDictionary(dictionaryDatabase, testDictionarySource, defaultImportDetails);
147
148 // Dictionary already imported
149 expect.soft(await createDictionaryImporter(expect).importDictionary(dictionaryDatabase, testDictionarySource, defaultImportDetails)).toEqual({result: null, errors: [new Error('Dictionary Test Dictionary is already imported, skipped it.')]});
150
151 await dictionaryDatabase.close();
152 });
153 describe('Invalid dictionaries', () => {
154 const invalidDictionaries = [
155 {name: 'invalid-dictionary1'},
156 {name: 'invalid-dictionary2'},
157 {name: 'invalid-dictionary3'},
158 {name: 'invalid-dictionary4'},
159 {name: 'invalid-dictionary5'},
160 {name: 'invalid-dictionary6'},
161 ];
162 describe.each(invalidDictionaries)('Invalid dictionary: $name', ({name}) => {
163 test('Has invalid data', async ({expect}) => {
164 const dictionaryDatabase = new DictionaryDatabase();
165 await dictionaryDatabase.prepare();
166
167 const testDictionarySource = await createTestDictionaryArchiveData(name);
168
169 /** @type {import('dictionary-importer').ImportDetails} */
170 const detaultImportDetails = {prefixWildcardsSupported: false, yomitanVersion: '0.0.0.0'};
171 await expect.soft(createDictionaryImporter(expect).importDictionary(dictionaryDatabase, testDictionarySource, detaultImportDetails)).rejects.toThrow('Dictionary has invalid data');
172 await dictionaryDatabase.close();
173 });
174 });
175 });
176 describe('Database valid usage', () => {
177 const testDataFilePath = join(dirname, 'data/database-test-cases.json');
178 /** @type {import('test/database').DatabaseTestData} */
179 const testData = parseJson(readFileSync(testDataFilePath, {encoding: 'utf8'}));
180 test('Import data and test', async ({expect}) => {
181 const fakeImportDate = testData.expectedSummary.importDate;
182
183 // Load dictionary data
184 const testDictionarySource = await createTestDictionaryArchiveData('valid-dictionary1');
185 const testDictionaryIndex = await getDictionaryArchiveIndex(testDictionarySource);
186
187 const title = testDictionaryIndex.title;
188 const titles = new Map([
189 [title, {alias: title, allowSecondarySearches: false}],
190 ]);
191
192 // Setup database
193 const dictionaryDatabase = new DictionaryDatabase();
194 await dictionaryDatabase.prepare();
195
196 // Import data
197 let progressEvent1 = false;
198 const dictionaryImporter = createDictionaryImporter(expect, () => { progressEvent1 = true; });
199 const {result: importDictionaryResult, errors: importDictionaryErrors} = await dictionaryImporter.importDictionary(
200 dictionaryDatabase,
201 testDictionarySource,
202 {prefixWildcardsSupported: true, yomitanVersion: '0.0.0.0'},
203 );
204
205 if (importDictionaryResult) {
206 importDictionaryResult.importDate = fakeImportDate;
207 }
208
209 expect.soft(importDictionaryErrors).toStrictEqual([]);
210 expect.soft(importDictionaryResult).toStrictEqual(testData.expectedSummary);
211 expect.soft(progressEvent1).toBe(true);
212
213 // Get info summary
214 const info = await dictionaryDatabase.getDictionaryInfo();
215 for (const item of info) { item.importDate = fakeImportDate; }
216 expect.soft(info).toStrictEqual([testData.expectedSummary]);
217
218 // Get counts
219 const counts = await dictionaryDatabase.getDictionaryCounts(info.map((v) => v.title), true);
220 expect.soft(counts).toStrictEqual(testData.expectedCounts);
221
222 // Test findTermsBulk
223 for (const {inputs, expectedResults} of testData.tests.findTermsBulk) {
224 for (const {termList, matchType} of inputs) {
225 const results = await dictionaryDatabase.findTermsBulk(termList, titles, matchType);
226 expect.soft(results.length).toStrictEqual(expectedResults.total);
227 for (const [term, count] of expectedResults.terms) {
228 expect.soft(countDictionaryDatabaseEntriesWithTerm(results, term)).toStrictEqual(count);
229 }
230 for (const [reading, count] of expectedResults.readings) {
231 expect.soft(countDictionaryDatabaseEntriesWithReading(results, reading)).toStrictEqual(count);
232 }
233 }
234 }
235
236 // Test findTermsExactBulk
237 for (const {inputs, expectedResults} of testData.tests.findTermsExactBulk) {
238 for (const {termList} of inputs) {
239 const results = await dictionaryDatabase.findTermsExactBulk(termList, titles);
240 expect.soft(results.length).toStrictEqual(expectedResults.total);
241 for (const [term, count] of expectedResults.terms) {
242 expect.soft(countDictionaryDatabaseEntriesWithTerm(results, term)).toStrictEqual(count);
243 }
244 for (const [reading, count] of expectedResults.readings) {
245 expect.soft(countDictionaryDatabaseEntriesWithReading(results, reading)).toStrictEqual(count);
246 }
247 }
248 }
249
250 // Test findTermsBySequenceBulk
251 for (const {inputs, expectedResults} of testData.tests.findTermsBySequenceBulk) {
252 for (const {sequenceList} of inputs) {
253 const results = await dictionaryDatabase.findTermsBySequenceBulk(sequenceList.map((query) => ({query, dictionary: title})));
254 expect.soft(results.length).toStrictEqual(expectedResults.total);
255 for (const [term, count] of expectedResults.terms) {
256 expect.soft(countDictionaryDatabaseEntriesWithTerm(results, term)).toStrictEqual(count);
257 }
258 for (const [reading, count] of expectedResults.readings) {
259 expect.soft(countDictionaryDatabaseEntriesWithReading(results, reading)).toStrictEqual(count);
260 }
261 }
262 }
263
264 // Test findTermMetaBulk
265 for (const {inputs, expectedResults} of testData.tests.findTermMetaBulk) {
266 for (const {termList} of inputs) {
267 const results = await dictionaryDatabase.findTermMetaBulk(termList, titles);
268 expect.soft(results.length).toStrictEqual(expectedResults.total);
269 for (const [mode, count] of expectedResults.modes) {
270 expect.soft(countMetasWithMode(results, mode)).toStrictEqual(count);
271 }
272 }
273 }
274
275 // Test findKanjiBulk
276 for (const {inputs, expectedResults} of testData.tests.findKanjiBulk) {
277 for (const {kanjiList} of inputs) {
278 const results = await dictionaryDatabase.findKanjiBulk(kanjiList, titles);
279 expect.soft(results.length).toStrictEqual(expectedResults.total);
280 for (const [kanji, count] of expectedResults.kanji) {
281 expect.soft(countKanjiWithCharacter(results, kanji)).toStrictEqual(count);
282 }
283 }
284 }
285
286 // Test findKanjiBulk
287 for (const {inputs, expectedResults} of testData.tests.findKanjiMetaBulk) {
288 for (const {kanjiList} of inputs) {
289 const results = await dictionaryDatabase.findKanjiMetaBulk(kanjiList, titles);
290 expect.soft(results.length).toStrictEqual(expectedResults.total);
291 for (const [mode, count] of expectedResults.modes) {
292 expect.soft(countMetasWithMode(results, mode)).toStrictEqual(count);
293 }
294 }
295 }
296
297 // Test findTagForTitle
298 for (const {inputs, expectedResults} of testData.tests.findTagForTitle) {
299 for (const {name} of inputs) {
300 const result = await dictionaryDatabase.findTagForTitle(name, title);
301 expect.soft(result).toStrictEqual(expectedResults.value);
302 }
303 }
304
305 // Close
306 await dictionaryDatabase.close();
307 });
308 });
309 describe('Database cleanup', () => {
310 /** @type {{clearMethod: 'purge'|'delete'}[]} */
311 const cleanupTestCases = [
312 {clearMethod: 'purge'},
313 {clearMethod: 'delete'},
314 ];
315 describe.each(cleanupTestCases)('Testing cleanup method $clearMethod', ({clearMethod}) => {
316 test('Import data and test', async ({expect}) => {
317 // Load dictionary data
318 const testDictionarySource = await createTestDictionaryArchiveData('valid-dictionary1');
319 const testDictionaryIndex = await getDictionaryArchiveIndex(testDictionarySource);
320
321 // Setup database
322 const dictionaryDatabase = new DictionaryDatabase();
323 await dictionaryDatabase.prepare();
324
325 // Import data
326 const dictionaryImporter = createDictionaryImporter(expect);
327 await dictionaryImporter.importDictionary(dictionaryDatabase, testDictionarySource, {prefixWildcardsSupported: true, yomitanVersion: '0.0.0.0'});
328
329 // Clear
330 switch (clearMethod) {
331 case 'purge':
332 await dictionaryDatabase.purge();
333 break;
334 case 'delete':
335 {
336 let progressEvent2 = false;
337 await dictionaryDatabase.deleteDictionary(
338 testDictionaryIndex.title,
339 1000,
340 () => { progressEvent2 = true; },
341 );
342 expect(progressEvent2).toBe(true);
343 }
344 break;
345 }
346
347 // Test empty
348 const info = await dictionaryDatabase.getDictionaryInfo();
349 expect.soft(info).toStrictEqual([]);
350
351 const counts = await dictionaryDatabase.getDictionaryCounts([], true);
352 /** @type {import('dictionary-database').DictionaryCounts} */
353 const countsExpected = {
354 counts: [],
355 total: {kanji: 0, kanjiMeta: 0, terms: 0, termMeta: 0, tagMeta: 0, media: 0},
356 };
357 expect.soft(counts).toStrictEqual(countsExpected);
358
359 // Close
360 await dictionaryDatabase.close();
361 });
362 });
363 });
364});