Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2024-2025 Yomitan Authors
3 *
4 * This program is free software: you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18import {describe, test} from 'vitest';
19import {LanguageTransformer} from '../ext/js/language/language-transformer.js';
20import {getAllLanguageTransformDescriptors} from '../ext/js/language/languages.js';
21
22class DeinflectionNode {
23 /**
24 * @param {string} text
25 * @param {string[]} ruleNames
26 * @param {?RuleNode} ruleNode
27 * @param {?DeinflectionNode} previous
28 */
29 constructor(text, ruleNames, ruleNode, previous) {
30 /** @type {string} */
31 this.text = text;
32 /** @type {string[]} */
33 this.ruleNames = ruleNames;
34 /** @type {?RuleNode} */
35 this.ruleNode = ruleNode;
36 /** @type {?DeinflectionNode} */
37 this.previous = previous;
38 }
39
40 /**
41 * @param {DeinflectionNode} other
42 * @returns {boolean}
43 */
44 historyIncludes(other) {
45 /** @type {?DeinflectionNode} */
46 // eslint-disable-next-line @typescript-eslint/no-this-alias
47 let node = this;
48 for (; node !== null; node = node.previous) {
49 if (
50 node.ruleNode === other.ruleNode &&
51 node.text === other.text &&
52 arraysAreEqual(node.ruleNames, other.ruleNames)
53 ) {
54 return true;
55 }
56 }
57 return false;
58 }
59
60 /**
61 * @returns {DeinflectionNode[]}
62 */
63 getHistory() {
64 /** @type {DeinflectionNode[]} */
65 const results = [];
66 /** @type {?DeinflectionNode} */
67 // eslint-disable-next-line @typescript-eslint/no-this-alias
68 let node = this;
69 for (; node !== null; node = node.previous) {
70 results.unshift(node);
71 }
72 return results;
73 }
74}
75
76class RuleNode {
77 /**
78 * @param {string} groupName
79 * @param {import('language-transformer').SuffixRule} rule
80 */
81 constructor(groupName, rule) {
82 /** @type {string} */
83 this.groupName = groupName;
84 /** @type {import('language-transformer').SuffixRule} */
85 this.rule = rule;
86 }
87}
88
89/**
90 * @template [T=unknown]
91 * @param {T[]} rules1
92 * @param {T[]} rules2
93 * @returns {boolean}
94 */
95function arraysAreEqual(rules1, rules2) {
96 if (rules1.length !== rules2.length) { return false; }
97 for (const rule1 of rules1) {
98 if (!rules2.includes(rule1)) { return false; }
99 }
100 return true;
101}
102
103const languagesWithTransforms = getAllLanguageTransformDescriptors();
104
105describe.each(languagesWithTransforms)('Cycles Test $iso', ({languageTransforms}) => {
106 test('Check for cycles', ({expect}) => {
107 const languageTransformer = new LanguageTransformer();
108 languageTransformer.addDescriptor(languageTransforms);
109
110 /** @type {RuleNode[]} */
111 const ruleNodes = [];
112 for (const [groupName, reasonInfo] of Object.entries(languageTransforms.transforms)) {
113 for (const rule of reasonInfo.rules) {
114 if (rule.type === 'suffix') {
115 ruleNodes.push(new RuleNode(groupName, /** @type {import('language-transformer').SuffixRule}*/ (rule)));
116 }
117 }
118 }
119
120 /** @type {DeinflectionNode[]} */
121 const deinflectionNodes = [];
122 for (const {rule: {isInflected}} of ruleNodes) {
123 const suffixIn = isInflected.source.substring(0, isInflected.source.length - 1);
124 deinflectionNodes.push(new DeinflectionNode(`?${suffixIn}`, [], null, null));
125 }
126
127 for (let i = 0; i < deinflectionNodes.length; ++i) {
128 const deinflectionNode = deinflectionNodes[i];
129 const {text, ruleNames} = deinflectionNode;
130 for (const ruleNode of ruleNodes) {
131 const {isInflected, deinflected: suffixOut, conditionsIn, conditionsOut} = ruleNode.rule;
132 const suffixIn = isInflected.source.substring(0, isInflected.source.length - 1);
133 if (
134 !LanguageTransformer.conditionsMatch(
135 languageTransformer.getConditionFlagsFromConditionTypes(ruleNames),
136 languageTransformer.getConditionFlagsFromConditionTypes(conditionsIn),
137 ) ||
138 !text.endsWith(suffixIn) ||
139 (text.length - suffixIn.length + suffixOut.length) <= 0
140 ) {
141 continue;
142 }
143
144 const newDeinflectionNode = new DeinflectionNode(
145 text.substring(0, text.length - suffixIn.length) + suffixOut,
146 conditionsOut,
147 ruleNode,
148 deinflectionNode,
149 );
150
151 // Cycle check
152 if (deinflectionNode.historyIncludes(newDeinflectionNode)) {
153 const stack = [];
154 for (const {text: itemText, ruleNode: itemNode} of newDeinflectionNode.getHistory()) {
155 if (itemNode !== null) {
156 const itemSuffixIn = itemNode.rule.isInflected.source.substring(0, itemNode.rule.isInflected.source.length - 1);
157 const itemSuffixOut = itemNode.rule.deinflected;
158 stack.push(`${itemText} (${itemNode.groupName}, ${itemNode.rule.conditionsIn.join(',')}=>${itemNode.rule.conditionsOut.join(',')}, ${itemSuffixIn}=>${itemSuffixOut})`);
159 } else {
160 stack.push(`${itemText} (start)`);
161 }
162 }
163 const message = `Cycle detected:\n ${stack.join('\n ')}`;
164 expect.soft(true, message).toEqual(false);
165 continue;
166 }
167
168 deinflectionNodes.push(newDeinflectionNode);
169 }
170 }
171 }, {timeout: 30 * 1000});
172});