Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 364 lines 17 kB view raw
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});