@jjlmoya/utils-hardware 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/i18n/de.ts +4 -4
- package/src/category/i18n/id.ts +3 -3
- package/src/category/i18n/it.ts +3 -3
- package/src/category/i18n/nl.ts +3 -3
- package/src/category/i18n/pl.ts +4 -4
- package/src/category/i18n/pt.ts +3 -3
- package/src/category/i18n/ru.ts +3 -3
- package/src/category/i18n/sv.ts +3 -3
- package/src/category/i18n/tr.ts +3 -3
- package/src/category/i18n/zh.ts +3 -3
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/layouts/PreviewLayout.astro +1 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/batteryHealthEstimator/component.astro +3 -3
- package/src/tool/batteryHealthEstimator/i18n/fr.ts +4 -4
- package/src/tool/batteryHealthEstimator/i18n/pl.ts +1 -1
- package/src/tool/colorAccuracyTest/color-accuracy-test.css +118 -5
- package/src/tool/colorAccuracyTest/i18n/de.ts +7 -7
- package/src/tool/colorAccuracyTest/i18n/en.ts +3 -3
- package/src/tool/colorAccuracyTest/i18n/es.ts +2 -2
- package/src/tool/colorAccuracyTest/i18n/fr.ts +9 -9
- package/src/tool/colorAccuracyTest/i18n/id.ts +3 -3
- package/src/tool/colorAccuracyTest/i18n/it.ts +3 -3
- package/src/tool/colorAccuracyTest/i18n/ja.ts +1 -1
- package/src/tool/colorAccuracyTest/i18n/ko.ts +2 -2
- package/src/tool/colorAccuracyTest/i18n/nl.ts +3 -3
- package/src/tool/colorAccuracyTest/i18n/pl.ts +5 -5
- package/src/tool/colorAccuracyTest/i18n/pt.ts +3 -3
- package/src/tool/colorAccuracyTest/i18n/ru.ts +15 -15
- package/src/tool/colorAccuracyTest/i18n/sv.ts +3 -3
- package/src/tool/colorAccuracyTest/i18n/tr.ts +2 -2
- package/src/tool/colorAccuracyTest/i18n/zh.ts +7 -7
- package/src/tool/deadPixelTest/i18n/ru.ts +6 -6
- package/src/tool/deadPixelTest/i18n/sv.ts +1 -1
- package/src/tool/deadPixelTest/i18n/zh.ts +5 -5
- package/src/tool/gamepadTest/component.astro +4 -3
- package/src/tool/gamepadTest/gamepad-test.css +171 -3
- package/src/tool/gamepadTest/i18n/es.ts +4 -4
- package/src/tool/gamepadTest/i18n/ru.ts +1 -1
- package/src/tool/gamepadTest/i18n/zh.ts +1 -1
- package/src/tool/gamepadVibrationTester/component.astro +3 -3
- package/src/tool/gamepadVibrationTester/i18n/es.ts +1 -1
- package/src/tool/gamepadVibrationTester/i18n/fr.ts +2 -2
- package/src/tool/keyboardTest/component.astro +6 -1
- package/src/tool/keyboardTest/keyboard-test.css +115 -2
- package/src/tool/mouseDoubleClickTest/bibliography.astro +14 -0
- package/src/tool/mouseDoubleClickTest/bibliography.ts +16 -0
- package/src/tool/mouseDoubleClickTest/component.astro +135 -0
- package/src/tool/mouseDoubleClickTest/entry.ts +29 -0
- package/src/tool/mouseDoubleClickTest/i18n/de.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/en.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/es.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/fr.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/id.ts +285 -0
- package/src/tool/mouseDoubleClickTest/i18n/it.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/ja.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/ko.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/nl.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/pl.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/pt.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/ru.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/sv.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/tr.ts +274 -0
- package/src/tool/mouseDoubleClickTest/i18n/zh.ts +274 -0
- package/src/tool/mouseDoubleClickTest/index.ts +9 -0
- package/src/tool/mouseDoubleClickTest/logic.ts +258 -0
- package/src/tool/mouseDoubleClickTest/mouse-double-click-test.css +488 -0
- package/src/tool/mouseDoubleClickTest/seo.astro +15 -0
- package/src/tool/mouseDoubleClickTest/ui.ts +26 -0
- package/src/tool/mousePollingTest/i18n/fr.ts +3 -3
- package/src/tool/mousePollingTest/i18n/pl.ts +1 -1
- package/src/tool/mousePollingTest/i18n/ru.ts +1 -1
- package/src/tool/mousePollingTest/i18n/zh.ts +1 -1
- package/src/tool/mousePollingTest/logic/RatonManager.ts +6 -6
- package/src/tool/refreshRateDetector/i18n/de.ts +3 -3
- package/src/tool/refreshRateDetector/i18n/en.ts +3 -3
- package/src/tool/refreshRateDetector/i18n/fr.ts +4 -4
- package/src/tool/refreshRateDetector/i18n/id.ts +3 -3
- package/src/tool/refreshRateDetector/i18n/pl.ts +2 -2
- package/src/tool/refreshRateDetector/i18n/pt.ts +3 -3
- package/src/tool/refreshRateDetector/i18n/sv.ts +3 -3
- package/src/tool/refreshRateDetector/i18n/tr.ts +2 -2
- package/src/tool/refreshRateDetector/i18n/zh.ts +2 -2
- package/src/tool/toneGenerator/component.astro +7 -7
- package/src/tool/toneGenerator/i18n/fr.ts +2 -2
- package/src/tool/toneGenerator/i18n/pl.ts +1 -1
- package/src/tool/toneGenerator/i18n/ru.ts +2 -2
- package/src/tool/toneGenerator/i18n/zh.ts +3 -3
- package/src/tools.ts +2 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getFiles(dir: string): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
if (!fs.existsSync(dir)) {
|
|
8
|
+
return results;
|
|
9
|
+
}
|
|
10
|
+
const list = fs.readdirSync(dir);
|
|
11
|
+
for (const file of list) {
|
|
12
|
+
const fullPath = path.join(dir, file);
|
|
13
|
+
const stat = fs.statSync(fullPath);
|
|
14
|
+
if (stat && stat.isDirectory()) {
|
|
15
|
+
if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
|
|
16
|
+
results.push(...getFiles(fullPath));
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
results.push(fullPath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isContentFile(filePath: string): boolean {
|
|
26
|
+
return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const srcDir = path.join(process.cwd(), 'src');
|
|
30
|
+
const scriptsDir = path.join(process.cwd(), 'scripts');
|
|
31
|
+
const filesToTest = [
|
|
32
|
+
...getFiles(srcDir).filter(isContentFile),
|
|
33
|
+
...getFiles(scriptsDir).filter(isContentFile),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const aiTypographyGarbage = [
|
|
37
|
+
'\u2013',
|
|
38
|
+
'\u2014',
|
|
39
|
+
'\u2026',
|
|
40
|
+
'\u201C',
|
|
41
|
+
'\u201D',
|
|
42
|
+
'\u2018',
|
|
43
|
+
'\u2019',
|
|
44
|
+
'\u00AB',
|
|
45
|
+
'\u00BB',
|
|
46
|
+
'\u200B',
|
|
47
|
+
'\u201E',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('Typography Garbage Character Validation', () => {
|
|
51
|
+
filesToTest.forEach((filePath) => {
|
|
52
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
53
|
+
it(`should not contain typography garbage characters in ${relativePath}`, () => {
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
55
|
+
const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
|
|
56
|
+
expect(hasAiPatterns).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`should not contain space before colon in ${relativePath}`, () => {
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const spaceBeforeColon = / : /.test(content);
|
|
62
|
+
expect(spaceBeforeColon).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it(`should not contain double hyphen in ${relativePath}`, () => {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
expect(content).not.toContain('--');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import type { SEOSection, ToolLocaleContent } from '../types';
|
|
6
|
+
|
|
7
|
+
const srcDir = join(process.cwd(), 'src');
|
|
8
|
+
const toolDir = join(srcDir, 'tool');
|
|
9
|
+
const geometryReads = [
|
|
10
|
+
'offsetWidth',
|
|
11
|
+
'offsetHeight',
|
|
12
|
+
'offsetTop',
|
|
13
|
+
'offsetLeft',
|
|
14
|
+
'clientWidth',
|
|
15
|
+
'clientHeight',
|
|
16
|
+
'clientTop',
|
|
17
|
+
'clientLeft',
|
|
18
|
+
'scrollWidth',
|
|
19
|
+
'scrollHeight',
|
|
20
|
+
'scrollTop',
|
|
21
|
+
'scrollLeft',
|
|
22
|
+
'getBoundingClientRect',
|
|
23
|
+
'getClientRects',
|
|
24
|
+
'computedStyle',
|
|
25
|
+
'getComputedStyle',
|
|
26
|
+
];
|
|
27
|
+
const domWrites = [
|
|
28
|
+
'.style.',
|
|
29
|
+
'.classList.add',
|
|
30
|
+
'.classList.remove',
|
|
31
|
+
'.classList.toggle',
|
|
32
|
+
'.appendChild',
|
|
33
|
+
'.insertBefore',
|
|
34
|
+
'.prepend',
|
|
35
|
+
'.append',
|
|
36
|
+
'.remove',
|
|
37
|
+
'.innerHTML',
|
|
38
|
+
'.textContent',
|
|
39
|
+
'.setAttribute',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const fullPath = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
47
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(file: string): string {
|
|
53
|
+
return relative(process.cwd(), file).replace(/\\/g, '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
57
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
61
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
62
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
66
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function controlStartIndex(content: string, tag: RegExpMatchArray): number {
|
|
70
|
+
return tag.index ?? content.indexOf(tag[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
74
|
+
const index = controlStartIndex(content, tag);
|
|
75
|
+
const before = content.slice(0, index);
|
|
76
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
77
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
78
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
79
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
83
|
+
const source = tag[0];
|
|
84
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
85
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
86
|
+
const id = attrValue(source, 'id');
|
|
87
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
88
|
+
return hasWrappingLabel(content, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVisuallyHiddenFileInput(tag: string): boolean {
|
|
92
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
93
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
94
|
+
const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
|
|
95
|
+
return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnoredInput(tag: string): boolean {
|
|
99
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
100
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
104
|
+
return findFormControls(content, tagName)
|
|
105
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
106
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
107
|
+
.map((tag) => tag[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
|
|
111
|
+
return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
115
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
return (
|
|
117
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
118
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function headingLevels(sections: SEOSection[]): number[] {
|
|
123
|
+
return sections
|
|
124
|
+
.filter((section) => section.type === 'title')
|
|
125
|
+
.map((section) => Number('level' in section ? section.level : 0))
|
|
126
|
+
.filter((level) => Number.isInteger(level) && level > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingLevelJumps(levels: number[]): string[] {
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
levels.forEach((level, index) => {
|
|
132
|
+
const previous = index === 0 ? 1 : levels[index - 1];
|
|
133
|
+
if (previous && level > previous + 1) {
|
|
134
|
+
failures.push(`h${previous} -> h${level}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return failures;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
141
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
142
|
+
return domWrites.some((write) => {
|
|
143
|
+
const writeIndex = normalized.indexOf(write);
|
|
144
|
+
if (writeIndex === -1) return false;
|
|
145
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('PageSpeed best-practice guards', () => {
|
|
150
|
+
const astroToolFiles = findFiles(toolDir, ['.astro']);
|
|
151
|
+
const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
|
|
152
|
+
|
|
153
|
+
astroToolFiles.forEach((file) => {
|
|
154
|
+
const displayPath = relativePath(file);
|
|
155
|
+
|
|
156
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
157
|
+
const content = readFileSync(file, 'utf-8');
|
|
158
|
+
const failures = controlFailures(content, 'input');
|
|
159
|
+
|
|
160
|
+
expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
164
|
+
const content = readFileSync(file, 'utf-8');
|
|
165
|
+
const failures = controlFailures(content, 'select');
|
|
166
|
+
|
|
167
|
+
expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ALL_TOOLS.forEach((tool) => {
|
|
172
|
+
Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
|
|
173
|
+
it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
|
|
174
|
+
if (!loader) return;
|
|
175
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
176
|
+
const levels = headingLevels(content.seo);
|
|
177
|
+
const failures = findHeadingLevelJumps(levels);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
failures,
|
|
181
|
+
`SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
scriptFiles.forEach((file) => {
|
|
188
|
+
const displayPath = relativePath(file);
|
|
189
|
+
|
|
190
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
191
|
+
const content = readFileSync(file, 'utf-8');
|
|
192
|
+
expect(
|
|
193
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
194
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
+
|
|
6
|
+
const SCRIPT_RULES = {
|
|
7
|
+
ja: {
|
|
8
|
+
language: 'Japanese',
|
|
9
|
+
scriptName: 'kana/kanji',
|
|
10
|
+
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
+
minScriptRatio: 0.45,
|
|
12
|
+
},
|
|
13
|
+
ko: {
|
|
14
|
+
language: 'Korean',
|
|
15
|
+
scriptName: 'hangul',
|
|
16
|
+
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
+
minScriptRatio: 0.55,
|
|
18
|
+
},
|
|
19
|
+
ru: {
|
|
20
|
+
language: 'Russian',
|
|
21
|
+
scriptName: 'cyrillic',
|
|
22
|
+
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
+
minScriptRatio: 0.65,
|
|
24
|
+
},
|
|
25
|
+
zh: {
|
|
26
|
+
language: 'Chinese',
|
|
27
|
+
scriptName: 'han',
|
|
28
|
+
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
+
minScriptRatio: 0.45,
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const LETTERS = /\p{L}/gu;
|
|
34
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
+
|
|
36
|
+
function collectStrings(value: unknown): string[] {
|
|
37
|
+
if (typeof value === 'string') return [value];
|
|
38
|
+
if (!value || typeof value !== 'object') return [];
|
|
39
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
+
return Object.values(value).flatMap(collectStrings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: unknown): string {
|
|
44
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function letterCount(text: string): number {
|
|
52
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
+
const letters = letterCount(text);
|
|
61
|
+
if (letters === 0) return 0;
|
|
62
|
+
return scriptCount(text, locale) / letters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Native script density validation', () => {
|
|
66
|
+
ALL_TOOLS.forEach((tool) => {
|
|
67
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
+
const typedLocale = locale as ScriptLocale;
|
|
71
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
+
if (!loader) return;
|
|
73
|
+
|
|
74
|
+
const content = await loader();
|
|
75
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
77
|
+
const letters = letterCount(text);
|
|
78
|
+
const matches = scriptCount(text, typedLocale);
|
|
79
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
ratio,
|
|
83
|
+
[
|
|
84
|
+
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
+
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -4,8 +4,8 @@ import { hardwareCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 10 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(10);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('hardwareCategory should be defined', () => {
|
|
@@ -37,7 +37,7 @@ const S = JSON.stringify({
|
|
|
37
37
|
<div class="bh-sliders">
|
|
38
38
|
<div class="bh-slider-group">
|
|
39
39
|
<div class="bh-slider-header">
|
|
40
|
-
<label class="bh-slider-label">{t.voltageLabel}</label>
|
|
40
|
+
<label for="bh-v" class="bh-slider-label">{t.voltageLabel}</label>
|
|
41
41
|
<span id="bh-v-val" class="bh-slider-val">3.70V</span>
|
|
42
42
|
</div>
|
|
43
43
|
<input type="range" id="bh-v" class="bh-slider" min="3.0" max="4.2" step="0.01" value="3.70" />
|
|
@@ -46,7 +46,7 @@ const S = JSON.stringify({
|
|
|
46
46
|
|
|
47
47
|
<div class="bh-slider-group">
|
|
48
48
|
<div class="bh-slider-header">
|
|
49
|
-
<label class="bh-slider-label">{t.cyclesLabel}</label>
|
|
49
|
+
<label for="bh-c" class="bh-slider-label">{t.cyclesLabel}</label>
|
|
50
50
|
<span id="bh-c-val" class="bh-slider-val">50</span>
|
|
51
51
|
</div>
|
|
52
52
|
<input type="range" id="bh-c" class="bh-slider" min="0" max="1000" step="1" value="50" />
|
|
@@ -54,7 +54,7 @@ const S = JSON.stringify({
|
|
|
54
54
|
|
|
55
55
|
<div class="bh-slider-group">
|
|
56
56
|
<div class="bh-slider-header">
|
|
57
|
-
<label class="bh-slider-label">{t.tempLabel}</label>
|
|
57
|
+
<label for="bh-t" class="bh-slider-label">{t.tempLabel}</label>
|
|
58
58
|
<span id="bh-t-val" class="bh-slider-val">25°C</span>
|
|
59
59
|
</div>
|
|
60
60
|
<input type="range" id="bh-t" class="bh-slider" min="0" max="60" step="1" value="25" />
|
|
@@ -93,7 +93,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
|
|
|
93
93
|
schemas: [faqSchema, howToSchema, appSchema],
|
|
94
94
|
bibliography,
|
|
95
95
|
seo: [
|
|
96
|
-
{ type: 'title', text: 'La chimie du temps
|
|
96
|
+
{ type: 'title', text: 'La chimie du temps: pourquoi les batteries lithium meurent', level: 2 },
|
|
97
97
|
{
|
|
98
98
|
type: 'paragraph',
|
|
99
99
|
html: "Une batterie lithium-ion n'est pas une boîte d'énergie statique, mais un écosystème chimique dynamique en dégradation constante depuis sa fabrication. Chaque cycle de charge et décharge, chaque variation de température et chaque minute à des tensions extrêmes contribue à la formation de sous-produits qui entravent le flux d'ions.",
|
|
@@ -103,7 +103,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
|
|
|
103
103
|
type: 'paragraph',
|
|
104
104
|
html: "<strong>Couche SEI :</strong> l'interface électrolyte solide croît avec le temps, consomme du lithium actif et augmente la résistance interne. <strong>Oxydation de l'électrolyte :</strong> des tensions supérieures à 4,1V accélèrent l'oxydation et peuvent gonfler la batterie. <strong>Lithium Plating :</strong> charger à basse température dépose du lithium sous forme métallique, créant des dendrites qui peuvent percer le séparateur.",
|
|
105
105
|
},
|
|
106
|
-
{ type: 'title', text: "Le mythe des 100
|
|
106
|
+
{ type: 'title', text: "Le mythe des 100%: pourquoi charger toute la nuit est une erreur", level: 3 },
|
|
107
107
|
{
|
|
108
108
|
type: 'paragraph',
|
|
109
109
|
html: "Pour un ion lithium, être à 100% de charge (4,2V) est un état de haute tension. Les recherches montrent que la durée de vie double ou triple si l'on maintient l'appareil entre <strong>20% et 80%</strong>. De plus, pour chaque hausse de 10°C au-dessus de 25°C, la vitesse de dégradation chimique double approximativement.",
|
|
@@ -111,7 +111,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
|
|
|
111
111
|
{ type: 'title', text: 'Protocole de survie énergétique', level: 3 },
|
|
112
112
|
{
|
|
113
113
|
type: 'paragraph',
|
|
114
|
-
html: "Ne chargez jamais une batterie en dessous de 0°C
|
|
114
|
+
html: "Ne chargez jamais une batterie en dessous de 0°C: le lithium se dépose sur l'anode, causant des dommages permanents. La charge rapide génère de la chaleur localisée et du stress mécanique ; utilisez-la uniquement en cas de stricte nécessité. Pour un stockage prolongé, conservez la batterie à 50% dans un endroit frais.",
|
|
115
115
|
},
|
|
116
116
|
],
|
|
117
117
|
ui: {
|
|
@@ -122,7 +122,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
|
|
|
122
122
|
voltageLabel: 'Tension Actuelle',
|
|
123
123
|
cyclesLabel: 'Cycles de Charge',
|
|
124
124
|
tempLabel: 'Température',
|
|
125
|
-
voltageHint: 'Plage nominale
|
|
125
|
+
voltageHint: 'Plage nominale: 3,0V (vide) à 4,2V (plein).',
|
|
126
126
|
labelUsefulLife: 'Durée de Vie',
|
|
127
127
|
yearsPrefix: 'Est.',
|
|
128
128
|
yearsSuffix: 'ans',
|
|
@@ -106,7 +106,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
|
|
|
106
106
|
{ type: 'title', text: 'Mit 100%: dlaczego ładowanie przez noc to błąd', level: 3 },
|
|
107
107
|
{
|
|
108
108
|
type: 'paragraph',
|
|
109
|
-
html: 'Dla jonu litu stan naładowania 100% (4.2V) jest stanem wysokiego stresu. Badania pokazują, że żywotność cyklu wzrasta dwu- lub trzykrotnie przy utrzymywaniu urządzenia w zakresie <strong>20%
|
|
109
|
+
html: 'Dla jonu litu stan naładowania 100% (4.2V) jest stanem wysokiego stresu. Badania pokazują, że żywotność cyklu wzrasta dwu- lub trzykrotnie przy utrzymywaniu urządzenia w zakresie <strong>20% - 80%</strong>. Ponadto na każde 10°C powyżej 25°C tempo degradacji chemicznej wzrasta około dwukrotnie.',
|
|
110
110
|
},
|
|
111
111
|
{ type: 'title', text: 'Protokół przetrwania energii', level: 3 },
|
|
112
112
|
{
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
margin: 0 auto;
|
|
21
21
|
padding: 1rem;
|
|
22
22
|
color: var(--sc-text);
|
|
23
|
+
overflow-x: clip;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.sc-wrapper *,
|
|
27
|
+
.sc-wrapper *::before,
|
|
28
|
+
.sc-wrapper *::after {
|
|
29
|
+
box-sizing: border-box;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
.theme-dark .sc-wrapper {
|
|
@@ -44,6 +51,7 @@
|
|
|
44
51
|
transition: all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
45
52
|
position: relative;
|
|
46
53
|
overflow: hidden;
|
|
54
|
+
max-width: 100%;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
.sc-dashboard::before {
|
|
@@ -68,13 +76,18 @@
|
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
@media (max-width: 768px) {
|
|
79
|
+
.sc-wrapper {
|
|
80
|
+
padding: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
.sc-grid {
|
|
72
84
|
grid-template-columns: 1fr;
|
|
73
|
-
gap:
|
|
85
|
+
gap: 1.25rem;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
.sc-dashboard {
|
|
77
|
-
|
|
89
|
+
border-radius: 0.875rem;
|
|
90
|
+
padding: 1rem;
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
|
|
@@ -160,6 +173,7 @@
|
|
|
160
173
|
padding: 0.5rem;
|
|
161
174
|
border-radius: 0.875rem;
|
|
162
175
|
border: 1px solid var(--sc-border);
|
|
176
|
+
min-width: 0;
|
|
163
177
|
}
|
|
164
178
|
|
|
165
179
|
.sc-gamut-btn {
|
|
@@ -173,6 +187,7 @@
|
|
|
173
187
|
background: transparent;
|
|
174
188
|
color: var(--sc-text-muted);
|
|
175
189
|
position: relative;
|
|
190
|
+
min-width: 0;
|
|
176
191
|
}
|
|
177
192
|
|
|
178
193
|
.sc-gamut-btn.sc-gamut-active {
|
|
@@ -220,6 +235,7 @@
|
|
|
220
235
|
display: flex;
|
|
221
236
|
gap: 1rem;
|
|
222
237
|
animation: fade-in-up 0.8s ease-out 0.4s both;
|
|
238
|
+
width: 100%;
|
|
223
239
|
}
|
|
224
240
|
|
|
225
241
|
.sc-btn {
|
|
@@ -236,6 +252,7 @@
|
|
|
236
252
|
gap: 0.5rem;
|
|
237
253
|
position: relative;
|
|
238
254
|
overflow: hidden;
|
|
255
|
+
min-width: 0;
|
|
239
256
|
}
|
|
240
257
|
|
|
241
258
|
.sc-btn::before {
|
|
@@ -292,6 +309,7 @@
|
|
|
292
309
|
color: var(--sc-text-light);
|
|
293
310
|
margin-top: 1.5rem;
|
|
294
311
|
animation: fade-in-up 0.8s ease-out 0.5s both;
|
|
312
|
+
flex-wrap: wrap;
|
|
295
313
|
}
|
|
296
314
|
|
|
297
315
|
.sc-shortcut {
|
|
@@ -315,12 +333,12 @@
|
|
|
315
333
|
justify-content: center;
|
|
316
334
|
align-items: center;
|
|
317
335
|
animation: fade-in-right 0.8s ease-out 0.3s both;
|
|
336
|
+
min-width: 0;
|
|
318
337
|
}
|
|
319
338
|
|
|
320
339
|
.sc-preview-canvas {
|
|
321
340
|
width: 100%;
|
|
322
341
|
aspect-ratio: 1;
|
|
323
|
-
max-width: 350px;
|
|
324
342
|
border-radius: 1rem;
|
|
325
343
|
background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 50%, #1e1b4b 100%);
|
|
326
344
|
border: 2px solid var(--sc-border);
|
|
@@ -331,6 +349,7 @@
|
|
|
331
349
|
flex-direction: column;
|
|
332
350
|
justify-content: center;
|
|
333
351
|
align-items: center;
|
|
352
|
+
max-width: min(350px, 100%);
|
|
334
353
|
}
|
|
335
354
|
|
|
336
355
|
.sc-preview-gradient {
|
|
@@ -362,6 +381,8 @@
|
|
|
362
381
|
letter-spacing: 0.05em;
|
|
363
382
|
text-transform: uppercase;
|
|
364
383
|
z-index: 10;
|
|
384
|
+
width: calc(100% - 2rem);
|
|
385
|
+
text-align: center;
|
|
365
386
|
}
|
|
366
387
|
|
|
367
388
|
#test-overlay {
|
|
@@ -402,6 +423,8 @@
|
|
|
402
423
|
z-index: 1001;
|
|
403
424
|
backdrop-filter: blur(10px);
|
|
404
425
|
animation: slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
426
|
+
max-height: 55vh;
|
|
427
|
+
overflow-y: auto;
|
|
405
428
|
}
|
|
406
429
|
|
|
407
430
|
@media (min-width: 768px) {
|
|
@@ -419,11 +442,24 @@
|
|
|
419
442
|
@keyframes slide-up {
|
|
420
443
|
from {
|
|
421
444
|
opacity: 0;
|
|
422
|
-
transform:
|
|
445
|
+
transform: translateY(20px);
|
|
423
446
|
}
|
|
424
447
|
to {
|
|
425
448
|
opacity: 1;
|
|
426
|
-
transform:
|
|
449
|
+
transform: translateY(0);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
@media (min-width: 768px) {
|
|
454
|
+
@keyframes slide-up {
|
|
455
|
+
from {
|
|
456
|
+
opacity: 0;
|
|
457
|
+
transform: translateX(-50%) translateY(20px);
|
|
458
|
+
}
|
|
459
|
+
to {
|
|
460
|
+
opacity: 1;
|
|
461
|
+
transform: translateX(-50%) translateY(0);
|
|
462
|
+
}
|
|
427
463
|
}
|
|
428
464
|
}
|
|
429
465
|
|
|
@@ -675,6 +711,7 @@
|
|
|
675
711
|
display: flex;
|
|
676
712
|
gap: 0.5rem;
|
|
677
713
|
margin-top: 0.75rem;
|
|
714
|
+
width: 100%;
|
|
678
715
|
}
|
|
679
716
|
|
|
680
717
|
@media (min-width: 768px) {
|
|
@@ -695,6 +732,7 @@
|
|
|
695
732
|
font-weight: 600;
|
|
696
733
|
cursor: pointer;
|
|
697
734
|
transition: all 0.2s ease;
|
|
735
|
+
min-width: 0;
|
|
698
736
|
}
|
|
699
737
|
|
|
700
738
|
@media (min-width: 768px) {
|
|
@@ -726,3 +764,78 @@
|
|
|
726
764
|
.sc-action-secondary:hover {
|
|
727
765
|
background: rgba(255, 255, 255, 0.15);
|
|
728
766
|
}
|
|
767
|
+
|
|
768
|
+
@media (max-width: 640px) {
|
|
769
|
+
.sc-content {
|
|
770
|
+
min-width: 0;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.sc-gamut-toggle {
|
|
774
|
+
gap: 0.375rem;
|
|
775
|
+
padding: 0.375rem;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.sc-gamut-btn {
|
|
779
|
+
padding: 0.65rem 0.5rem;
|
|
780
|
+
font-size: 0.8rem;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.sc-buttons {
|
|
784
|
+
display: grid;
|
|
785
|
+
grid-template-columns: 1fr;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.sc-btn {
|
|
789
|
+
width: 100%;
|
|
790
|
+
padding: 0.875rem 1rem;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
.sc-shortcuts {
|
|
794
|
+
gap: 0.5rem;
|
|
795
|
+
font-size: 0.75rem;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.sc-shortcuts > span {
|
|
799
|
+
display: none;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.sc-shortcut {
|
|
803
|
+
flex: 1 1 8rem;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.sc-preview-canvas {
|
|
807
|
+
max-width: min(18rem, 100%);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.sc-preview-label {
|
|
811
|
+
bottom: 0.75rem;
|
|
812
|
+
font-size: 0.7rem;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.sc-controls {
|
|
816
|
+
padding: 3.75rem 0.75rem 0.75rem;
|
|
817
|
+
max-height: 70vh;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
.sc-close-test {
|
|
821
|
+
top: 0.75rem;
|
|
822
|
+
right: 0.75rem;
|
|
823
|
+
width: 2.5rem;
|
|
824
|
+
height: 2.5rem;
|
|
825
|
+
font-size: 1.6rem;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
.sc-test-description {
|
|
829
|
+
margin-top: 0;
|
|
830
|
+
font-size: 0.82rem;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
.sc-test-actions {
|
|
834
|
+
display: grid;
|
|
835
|
+
grid-template-columns: 1fr 1fr;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
.sc-action-btn {
|
|
839
|
+
padding: 0.7rem 0.5rem;
|
|
840
|
+
}
|
|
841
|
+
}
|