Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 291 lines 12 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 {fileURLToPath} from 'node:url'; 20import path from 'path'; 21import {afterAll, describe, expect, test} from 'vitest'; 22import {parseJson} from '../dev/json.js'; 23import {DOMTextScanner} from '../ext/js/dom/dom-text-scanner.js'; 24import {TextSourceElement} from '../ext/js/dom/text-source-element.js'; 25import {TextSourceGenerator} from '../ext/js/dom/text-source-generator.js'; 26import {TextSourceRange} from '../ext/js/dom/text-source-range.js'; 27import {setupDomTest} from './fixtures/dom-test.js'; 28 29const dirname = path.dirname(fileURLToPath(import.meta.url)); 30 31// DOMRect class definition 32class DOMRect { 33 /** 34 * @param {number} x 35 * @param {number} y 36 * @param {number} width 37 * @param {number} height 38 */ 39 constructor(x, y, width, height) { 40 /** @type {number} */ 41 this._x = x; 42 /** @type {number} */ 43 this._y = y; 44 /** @type {number} */ 45 this._width = width; 46 /** @type {number} */ 47 this._height = height; 48 } 49 50 /** @type {number} */ 51 get x() { return this._x; } 52 /** @type {number} */ 53 get y() { return this._y; } 54 /** @type {number} */ 55 get width() { return this._width; } 56 /** @type {number} */ 57 get height() { return this._height; } 58 /** @type {number} */ 59 get left() { return this._x + Math.min(0, this._width); } 60 /** @type {number} */ 61 get right() { return this._x + Math.max(0, this._width); } 62 /** @type {number} */ 63 get top() { return this._y + Math.min(0, this._height); } 64 /** @type {number} */ 65 get bottom() { return this._y + Math.max(0, this._height); } 66 /** @returns {string} */ 67 toJSON() { return '<not implemented>'; } 68} 69 70 71/** 72 * @param {Element} element 73 * @param {string|undefined} selector 74 * @returns {?Element} 75 */ 76function querySelectorChildOrSelf(element, selector) { 77 return selector ? element.querySelector(selector) : element; 78} 79 80/** 81 * @param {import('jsdom').DOMWindow} window 82 * @param {?Node} node 83 * @returns {?Text|Node} 84 */ 85function getChildTextNodeOrSelf(window, node) { 86 if (node === null) { return null; } 87 const Node = window.Node; 88 const childNode = node.firstChild; 89 return (childNode !== null && childNode.nodeType === Node.TEXT_NODE ? childNode : node); 90} 91 92/** 93 * @param {unknown} value 94 * @returns {unknown} 95 */ 96function getPrototypeOfOrNull(value) { 97 try { 98 return Object.getPrototypeOf(value); 99 } catch (e) { 100 return null; 101 } 102} 103 104/** 105 * @param {Document} document 106 * @returns {?Element} 107 */ 108function findImposterElement(document) { 109 // Finds the imposter element based on it's z-index style 110 return document.querySelector('div[style*="2147483646"]>*'); 111} 112 113const documentUtilTestEnv = await setupDomTest(path.join(dirname, 'data/html/document-util.html')); 114 115describe('Document utility tests', () => { 116 const {window, teardown} = documentUtilTestEnv; 117 afterAll(() => teardown(global)); 118 119 describe('DocumentUtil', () => { 120 describe('Text scanning functions', () => { 121 let testIndex = 0; 122 const {document} = window; 123 for (const testElement of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('test-case[data-test-type=scan]'))) { 124 test(`test-case-${testIndex++}`, () => { 125 // Get test parameters 126 /** @type {import('test/document-util').DocumentUtilTestData} */ 127 const { 128 elementFromPointSelector, 129 caretRangeFromPointSelector, 130 startNodeSelector, 131 startOffset, 132 endNodeSelector, 133 endOffset, 134 resultType, 135 sentenceScanExtent, 136 sentence, 137 hasImposter, 138 terminateAtNewlines, 139 } = parseJson(/** @type {string} */ (testElement.dataset.testData)); 140 141 const elementFromPointValue = querySelectorChildOrSelf(testElement, elementFromPointSelector); 142 const caretRangeFromPointValue = querySelectorChildOrSelf(testElement, caretRangeFromPointSelector); 143 const startNode = getChildTextNodeOrSelf(window, querySelectorChildOrSelf(testElement, startNodeSelector)); 144 const endNode = getChildTextNodeOrSelf(window, querySelectorChildOrSelf(testElement, endNodeSelector)); 145 146 // Defaults to true 147 const terminateAtNewlines2 = typeof terminateAtNewlines === 'boolean' ? terminateAtNewlines : true; 148 149 expect(elementFromPointValue).not.toStrictEqual(null); 150 expect(caretRangeFromPointValue).not.toStrictEqual(null); 151 expect(startNode).not.toStrictEqual(null); 152 expect(endNode).not.toStrictEqual(null); 153 154 // Setup functions 155 document.elementFromPoint = () => elementFromPointValue; 156 157 document.caretRangeFromPoint = (x, y) => { 158 const imposter = getChildTextNodeOrSelf(window, findImposterElement(document)); 159 expect(!!imposter).toStrictEqual(!!hasImposter); 160 161 const range = document.createRange(); 162 range.setStart(/** @type {Node} */ (imposter ?? startNode), startOffset); 163 range.setEnd(/** @type {Node} */ (imposter ?? startNode), endOffset); 164 165 // Override getClientRects to return a rect guaranteed to contain (x, y) 166 range.getClientRects = () => { 167 /** @type {import('test/document-types').PseudoDOMRectList} */ 168 // eslint-disable-next-line sonarjs/prefer-immediate-return 169 const domRectList = Object.assign( 170 [new DOMRect(x - 1, y - 1, 2, 2)], 171 { 172 /** 173 * @this {DOMRect[]} 174 * @param {number} index 175 * @returns {DOMRect} 176 */ 177 item: function item(index) { return this[index]; }, 178 }, 179 ); 180 return domRectList; 181 }; 182 return range; 183 }; 184 185 // Test docRangeFromPoint 186 const textSourceGenerator = new TextSourceGenerator(); 187 const source = textSourceGenerator.getRangeFromPoint(0, 0, { 188 deepContentScan: false, 189 normalizeCssZoom: true, 190 language: null, 191 }); 192 switch (resultType) { 193 case 'TextSourceRange': 194 expect(getPrototypeOfOrNull(source)).toStrictEqual(TextSourceRange.prototype); 195 break; 196 case 'TextSourceElement': 197 expect(getPrototypeOfOrNull(source)).toStrictEqual(TextSourceElement.prototype); 198 break; 199 case 'null': 200 expect(source).toStrictEqual(null); 201 break; 202 default: 203 expect.unreachable(); 204 break; 205 } 206 if (source === null) { return; } 207 208 // Sentence info 209 const terminatorString = '…。..??!!'; 210 /** @type {import('text-scanner').SentenceTerminatorMap} */ 211 const terminatorMap = new Map(); 212 for (const char of terminatorString) { 213 terminatorMap.set(char, [false, true]); 214 } 215 const quoteArray = [['「', '」'], ['『', '』'], ['\'', '\''], ['"', '"']]; 216 /** @type {import('text-scanner').SentenceForwardQuoteMap} */ 217 const forwardQuoteMap = new Map(); 218 /** @type {import('text-scanner').SentenceBackwardQuoteMap} */ 219 const backwardQuoteMap = new Map(); 220 for (const [char1, char2] of quoteArray) { 221 forwardQuoteMap.set(char1, [char2, false]); 222 backwardQuoteMap.set(char2, [char1, false]); 223 } 224 225 // Test docSentenceExtract 226 const sentenceActual = textSourceGenerator.extractSentence( 227 source, 228 false, 229 sentenceScanExtent, 230 terminateAtNewlines2, 231 terminatorMap, 232 forwardQuoteMap, 233 backwardQuoteMap, 234 ).text; 235 expect(sentenceActual).toStrictEqual(sentence); 236 237 // Clean 238 source.cleanup(); 239 }); 240 } 241 }); 242 }); 243 244 describe('DOMTextScanner', () => { 245 describe('Seek functions', () => { 246 let testIndex = 0; 247 const {document} = window; 248 for (const testElement of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('test-case[data-test-type=text-source-range-seek]'))) { 249 test(`test-case-${testIndex++}`, () => { 250 // Get test parameters 251 /** @type {import('test/document-util').DOMTextScannerTestData} */ 252 const { 253 seekNodeSelector, 254 seekNodeIsText, 255 seekOffset, 256 seekLength, 257 seekDirection, 258 expectedResultNodeSelector, 259 expectedResultNodeIsText, 260 expectedResultOffset, 261 expectedResultContent, 262 } = parseJson(/** @type {string} */ (testElement.dataset.testData)); 263 264 /** @type {?Node} */ 265 let seekNode = testElement.querySelector(/** @type {string} */ (seekNodeSelector)); 266 if (seekNodeIsText && seekNode !== null) { 267 seekNode = seekNode.firstChild; 268 } 269 270 const expectedResultContent2 = expectedResultContent.join('\n'); 271 272 /** @type {?Node} */ 273 let expectedResultNode = testElement.querySelector(/** @type {string} */ (expectedResultNodeSelector)); 274 if (expectedResultNodeIsText && expectedResultNode !== null) { 275 expectedResultNode = expectedResultNode.firstChild; 276 } 277 278 const {node, offset, content} = ( 279 seekDirection === 'forward' ? 280 new DOMTextScanner(/** @type {Node} */ (seekNode), seekOffset, true, false).seek(seekLength) : 281 new DOMTextScanner(/** @type {Node} */ (seekNode), seekOffset, true, false).seek(-seekLength) 282 ); 283 284 expect(node).toStrictEqual(expectedResultNode); 285 expect(offset).toStrictEqual(expectedResultOffset); 286 expect(content).toStrictEqual(expectedResultContent2); 287 }); 288 } 289 }); 290 }); 291});