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