@jjlmoya/utils-science 1.48.0 → 1.50.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 +2 -2
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/spanish_leakage.test.ts +153 -0
- package/src/tests/translation_copy.test.ts +122 -0
- package/src/tool/black-hole-event-horizon-simulator/i18n/de.ts +79 -4
- package/src/tool/black-hole-event-horizon-simulator/i18n/fr.ts +86 -11
- package/src/tool/black-hole-event-horizon-simulator/i18n/it.ts +86 -11
- package/src/tool/black-hole-event-horizon-simulator/i18n/ja.ts +85 -10
- package/src/tool/black-hole-event-horizon-simulator/i18n/nl.ts +87 -12
- package/src/tool/black-hole-event-horizon-simulator/i18n/pt.ts +85 -10
- package/src/tool/black-hole-event-horizon-simulator/i18n/sv.ts +85 -10
- package/src/tool/black-hole-event-horizon-simulator/i18n/tr.ts +85 -11
- package/src/tool/black-hole-event-horizon-simulator/i18n/zh.ts +87 -12
- package/src/tool/conway-life-rule-lab/i18n/de.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/es.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/fr.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/id.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/it.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/ja.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/ko.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/nl.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/pl.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/pt.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/ru.ts +49 -9
- package/src/tool/conway-life-rule-lab/i18n/sv.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/tr.ts +46 -6
- package/src/tool/conway-life-rule-lab/i18n/zh.ts +46 -6
- package/src/tool/cosmic-inflation/i18n/ru.ts +30 -4
- package/src/tool/double-slit-decoherence/i18n/fr.ts +48 -4
- package/src/tool/double-slit-decoherence/i18n/it.ts +52 -8
- package/src/tool/double-slit-decoherence/i18n/pt.ts +53 -9
- package/src/tool/double-slit-decoherence/i18n/sv.ts +56 -8
- package/src/tool/dyson-sphere-energy-capture/i18n/de.ts +119 -38
- package/src/tool/dyson-sphere-energy-capture/i18n/fr.ts +116 -48
- package/src/tool/dyson-sphere-energy-capture/i18n/it.ts +96 -19
- package/src/tool/dyson-sphere-energy-capture/i18n/ja.ts +124 -24
- package/src/tool/dyson-sphere-energy-capture/i18n/ko.ts +122 -22
- package/src/tool/dyson-sphere-energy-capture/i18n/pl.ts +68 -14
- package/src/tool/dyson-sphere-energy-capture/i18n/pt.ts +126 -45
- package/src/tool/dyson-sphere-energy-capture/i18n/ru.ts +46 -9
- package/src/tool/dyson-sphere-energy-capture/i18n/sv.ts +134 -53
- package/src/tool/dyson-sphere-energy-capture/i18n/tr.ts +68 -14
- package/src/tool/dyson-sphere-energy-capture/i18n/zh.ts +124 -24
- package/src/tool/global-albedo-snowball-simulator/i18n/ru.ts +20 -7
- package/src/tool/lorenz-attractor/i18n/de.ts +35 -5
- package/src/tool/lorenz-attractor/i18n/fr.ts +35 -5
- package/src/tool/lorenz-attractor/i18n/id.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/it.ts +9 -0
- package/src/tool/lorenz-attractor/i18n/ja.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/ko.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/nl.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/pl.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/pt.ts +35 -5
- package/src/tool/lorenz-attractor/i18n/ru.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/sv.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/tr.ts +10 -0
- package/src/tool/lorenz-attractor/i18n/zh.ts +10 -0
- package/src/tool/mandelbrot-fractal/i18n/de.ts +27 -0
- package/src/tool/mandelbrot-fractal/i18n/fr.ts +18 -0
- package/src/tool/mandelbrot-fractal/i18n/it.ts +26 -8
- package/src/tool/mandelbrot-fractal/i18n/pl.ts +27 -9
- package/src/tool/mandelbrot-fractal/i18n/ru.ts +22 -4
- package/src/tool/mandelbrot-fractal/i18n/sv.ts +35 -8
- package/src/tool/natural-selection-drift/i18n/de.ts +125 -22
- package/src/tool/natural-selection-drift/i18n/id.ts +113 -5
- package/src/tool/natural-selection-drift/i18n/it.ts +74 -2
- package/src/tool/natural-selection-drift/i18n/ja.ts +113 -5
- package/src/tool/natural-selection-drift/i18n/ko.ts +113 -5
- package/src/tool/natural-selection-drift/i18n/nl.ts +121 -6
- package/src/tool/natural-selection-drift/i18n/pl.ts +136 -16
- package/src/tool/natural-selection-drift/i18n/pt.ts +110 -15
- package/src/tool/natural-selection-drift/i18n/ru.ts +142 -7
- package/src/tool/natural-selection-drift/i18n/sv.ts +110 -5
- package/src/tool/natural-selection-drift/i18n/tr.ts +160 -22
- package/src/tool/natural-selection-drift/i18n/zh.ts +113 -5
- package/src/tool/phase-diagram-critical-points/i18n/de.ts +44 -6
- package/src/tool/phase-diagram-critical-points/i18n/fr.ts +46 -8
- package/src/tool/planet-atmosphere-survival/i18n/ru.ts +118 -2
- package/src/tool/radioactive-decay/i18n/de.ts +112 -5
- package/src/tool/radioactive-decay/i18n/fr.ts +16 -0
- package/src/tool/radioactive-decay/i18n/id.ts +112 -8
- package/src/tool/radioactive-decay/i18n/it.ts +16 -0
- package/src/tool/radioactive-decay/i18n/ja.ts +112 -8
- package/src/tool/radioactive-decay/i18n/ko.ts +112 -8
- package/src/tool/radioactive-decay/i18n/nl.ts +112 -14
- package/src/tool/radioactive-decay/i18n/pl.ts +112 -7
- package/src/tool/radioactive-decay/i18n/pt.ts +112 -21
- package/src/tool/radioactive-decay/i18n/ru.ts +112 -8
- package/src/tool/radioactive-decay/i18n/sv.ts +112 -8
- package/src/tool/radioactive-decay/i18n/tr.ts +112 -8
- package/src/tool/radioactive-decay/i18n/zh.ts +112 -8
- package/src/tool/roche-limit-satellite-disruption/i18n/pl.ts +76 -0
- package/src/tool/temperature-timeline/i18n/de.ts +22 -2
- package/src/tool/temperature-timeline/i18n/fr.ts +41 -1
- package/src/tool/temperature-timeline/i18n/it.ts +30 -1
- package/src/tool/temperature-timeline/i18n/pt.ts +34 -1
- package/src/tool/temperature-timeline/i18n/ru.ts +29 -0
- package/src/tool/twin-paradox-visualizer/i18n/pt.ts +41 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-science",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"preversion": "npm run lint && npm run test",
|
|
31
|
+
"preversion": "npm run lint && npm run test && npm run build",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
|
34
34
|
"minor": "npm version minor",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
import type { KnownLocale } from '../types';
|
|
4
|
+
|
|
5
|
+
interface ExpectedCounts {
|
|
6
|
+
seo: number;
|
|
7
|
+
faq: number;
|
|
8
|
+
howTo: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countItems(arr: unknown[] | undefined): number {
|
|
12
|
+
return arr?.length ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyLocaleParity(
|
|
16
|
+
entry: typeof ALL_ENTRIES[number],
|
|
17
|
+
loc: KnownLocale,
|
|
18
|
+
expected: ExpectedCounts,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const locContent = await entry.i18n[loc]?.();
|
|
21
|
+
expect(locContent, `Locale ${loc} missing content`).toBeDefined();
|
|
22
|
+
|
|
23
|
+
const locSeoCount = countItems(locContent?.seo);
|
|
24
|
+
const locFaqCount = countItems(locContent?.faq);
|
|
25
|
+
const locHowToCount = countItems(locContent?.howTo);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
locSeoCount,
|
|
29
|
+
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
+
).toBe(expected.seo);
|
|
31
|
+
expect(
|
|
32
|
+
locFaqCount,
|
|
33
|
+
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
34
|
+
).toBe(expected.faq);
|
|
35
|
+
expect(
|
|
36
|
+
locHowToCount,
|
|
37
|
+
`Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
|
|
38
|
+
).toBe(expected.howTo);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
43
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
+
it('all 15 locales should have identical SEO section counts and types as English', async () => {
|
|
45
|
+
const enContent = await entry.i18n.en?.();
|
|
46
|
+
expect(enContent).toBeDefined();
|
|
47
|
+
const expected: ExpectedCounts = {
|
|
48
|
+
seo: countItems(enContent?.seo),
|
|
49
|
+
faq: countItems(enContent?.faq),
|
|
50
|
+
howTo: countItems(enContent?.howTo),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const locales = Object.keys(entry.i18n) as KnownLocale[];
|
|
54
|
+
for (const loc of locales) {
|
|
55
|
+
await verifyLocaleParity(entry, loc, expected);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const STRUCTURAL_KEYS = new Set([
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'url',
|
|
8
|
+
'icon',
|
|
9
|
+
'image',
|
|
10
|
+
'imageUrl',
|
|
11
|
+
'keywords',
|
|
12
|
+
'category',
|
|
13
|
+
'tags',
|
|
14
|
+
'toolId',
|
|
15
|
+
'type',
|
|
16
|
+
'locale',
|
|
17
|
+
'language',
|
|
18
|
+
'direction',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TRANSLATABLE_KEYS = [
|
|
22
|
+
'title',
|
|
23
|
+
'description',
|
|
24
|
+
'faqTitle',
|
|
25
|
+
'faq',
|
|
26
|
+
'howTo',
|
|
27
|
+
'seo',
|
|
28
|
+
'schemas',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const SPANISH_MARKERS = [
|
|
32
|
+
['sangre', /\bsangre\b/gi],
|
|
33
|
+
['molino', /\bmolino\b/gi],
|
|
34
|
+
['grano', /\bgrano\b/gi],
|
|
35
|
+
['paladar', /\bpaladar\b/gi],
|
|
36
|
+
['cuchillas', /\bcuchillas\b/gi],
|
|
37
|
+
['trozos', /\btrozos\b/gi],
|
|
38
|
+
['frescura', /\bfrescura\b/gi],
|
|
39
|
+
['ingresa', /\bingresa\b/gi],
|
|
40
|
+
['selecciona', /\bselecciona\b/gi],
|
|
41
|
+
['herramienta', /\bherramienta\b/gi],
|
|
42
|
+
['según', /\bsegún\b/gi],
|
|
43
|
+
['después', /\bdespués\b/gi],
|
|
44
|
+
['puedes', /\bpuedes\b/gi],
|
|
45
|
+
['debes', /\bdebes\b/gi],
|
|
46
|
+
['tus', /\btus\b/gi],
|
|
47
|
+
['a la vez', /\ba la vez\b/gi],
|
|
48
|
+
['los datos', /\blos datos\b/gi],
|
|
49
|
+
['las opciones', /\blas opciones\b/gi],
|
|
50
|
+
['el resultado', /\bel resultado\b/gi],
|
|
51
|
+
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
52
|
+
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
53
|
+
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
type UnknownRecord = Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
function normalize(value: string): string {
|
|
59
|
+
return value
|
|
60
|
+
.replace(/<[^>]*>/g, ' ')
|
|
61
|
+
.replace(/ /gi, ' ')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim()
|
|
64
|
+
.toLocaleLowerCase('es');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTechnicalInvariant(text: string): boolean {
|
|
68
|
+
return [
|
|
69
|
+
'data:image/svg+xml;base64',
|
|
70
|
+
'background-image: url',
|
|
71
|
+
'.layout-playground {',
|
|
72
|
+
'const samplerate =',
|
|
73
|
+
].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectString(value: string, output: string[]): void {
|
|
77
|
+
const text = normalize(value);
|
|
78
|
+
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
82
|
+
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
83
|
+
collectText(childValue, output, childKey);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectText(value: unknown, output: string[], key?: string): void {
|
|
88
|
+
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
89
|
+
if (typeof value === 'string') return collectString(value, output);
|
|
90
|
+
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
91
|
+
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadText(loader: unknown): Promise<string[]> {
|
|
95
|
+
if (typeof loader !== 'function') return [];
|
|
96
|
+
const module = await (loader as () => Promise<unknown>)();
|
|
97
|
+
const output: string[] = [];
|
|
98
|
+
if (module && typeof module === 'object') {
|
|
99
|
+
const record = module as UnknownRecord;
|
|
100
|
+
for (const key of TRANSLATABLE_KEYS) {
|
|
101
|
+
collectText(record[key], output, key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return output;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findSpanishMarkers(text: string[]): string[] {
|
|
108
|
+
const corpus = text.join(' ');
|
|
109
|
+
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
110
|
+
pattern.test(corpus) ? [label] : [],
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
115
|
+
const corpus = translated.join(' ');
|
|
116
|
+
return spanish
|
|
117
|
+
.filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
|
|
118
|
+
.sort((a, b) => b.length - a.length)
|
|
119
|
+
.slice(0, 3);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('Locales must not contain copied Spanish content', () => {
|
|
123
|
+
for (const entry of ALL_ENTRIES) {
|
|
124
|
+
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
125
|
+
const spanish = await loadText(entry.i18n.es);
|
|
126
|
+
const failures: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
129
|
+
if (locale === 'es') continue;
|
|
130
|
+
|
|
131
|
+
const translated = await loadText(loader);
|
|
132
|
+
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
133
|
+
const markerHits = findSpanishMarkers(translated);
|
|
134
|
+
|
|
135
|
+
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
136
|
+
const details = [
|
|
137
|
+
copiedFragments.length > 0
|
|
138
|
+
? `copied fragments: ${copiedFragments
|
|
139
|
+
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
140
|
+
.join(', ')}`
|
|
141
|
+
: '',
|
|
142
|
+
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
143
|
+
]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join('; ');
|
|
146
|
+
failures.push(`${locale}: ${details}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
expect(failures, failures.join('\n')).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const COPY_THRESHOLD = 0.9;
|
|
5
|
+
|
|
6
|
+
const STRUCTURAL_KEYS = new Set([
|
|
7
|
+
'@context',
|
|
8
|
+
'@type',
|
|
9
|
+
'applicationCategory',
|
|
10
|
+
'columns',
|
|
11
|
+
'highlight',
|
|
12
|
+
'icon',
|
|
13
|
+
'level',
|
|
14
|
+
'operatingSystem',
|
|
15
|
+
'position',
|
|
16
|
+
'positive',
|
|
17
|
+
'price',
|
|
18
|
+
'priceCurrency',
|
|
19
|
+
'slug',
|
|
20
|
+
'trend',
|
|
21
|
+
'type',
|
|
22
|
+
'url',
|
|
23
|
+
'value',
|
|
24
|
+
'variant',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function normalizeText(value: string): string {
|
|
28
|
+
return value
|
|
29
|
+
.replace(/<[^>]*>/g, ' ')
|
|
30
|
+
.replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
|
|
31
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
32
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLocaleLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectText(value: unknown, path: string, parts: string[]): void {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = normalizeText(value);
|
|
41
|
+
if (normalized.length >= 2) parts.push(normalized);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!value || typeof value !== 'object') return;
|
|
51
|
+
|
|
52
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
53
|
+
if (STRUCTURAL_KEYS.has(key)) return;
|
|
54
|
+
collectText(child, `${path}.${key}`, parts);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function localeCorpus(content: unknown): string {
|
|
59
|
+
if (!content || typeof content !== 'object') return '';
|
|
60
|
+
|
|
61
|
+
const record = content as Record<string, unknown>;
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
collectText(record.title, 'title', parts);
|
|
64
|
+
collectText(record.description, 'description', parts);
|
|
65
|
+
collectText(record.faqTitle, 'faqTitle', parts);
|
|
66
|
+
collectText(record.faq, 'faq', parts);
|
|
67
|
+
collectText(record.seo, 'seo', parts);
|
|
68
|
+
collectText(record.schemas, 'schemas', parts);
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tokenCounts(text: string): Map<string, number> {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
|
|
75
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copySimilarity(left: string, right: string): number {
|
|
81
|
+
const leftCounts = tokenCounts(left);
|
|
82
|
+
const rightCounts = tokenCounts(right);
|
|
83
|
+
const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
84
|
+
const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
85
|
+
if (leftTotal === 0 || rightTotal === 0) return 0;
|
|
86
|
+
|
|
87
|
+
let shared = 0;
|
|
88
|
+
for (const [token, count] of leftCounts) {
|
|
89
|
+
shared += Math.min(count, rightCounts.get(token) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Locales must not copy another locale wholesale', () => {
|
|
95
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
96
|
+
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
97
|
+
const corpora = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
100
|
+
if (!loader) continue;
|
|
101
|
+
corpora.set(locale, localeCorpus(await loader()));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const locales = [...corpora.keys()];
|
|
105
|
+
const violations: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
+
const left = locales[leftIndex];
|
|
110
|
+
const right = locales[rightIndex];
|
|
111
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
+
|
|
113
|
+
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
+
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -111,11 +111,11 @@ export const content: ToolLocaleContent = {
|
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
type: 'paragraph',
|
|
114
|
-
html: 'Dieser Schwarzes Loch Ereignishorizont Simulator verwandelt die Kernideen von Schwarzschild-schwarzen Löchern in ein interaktives visuelles Modell.
|
|
114
|
+
html: 'Dieser Schwarzes Loch Ereignishorizont Simulator verwandelt die Kernideen von Schwarzschild-schwarzen Löchern in ein interaktives visuelles Modell.',
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
type: 'paragraph',
|
|
118
|
-
html: 'Der Simulator wurde für Astronomiestudenten, Physiklehrer
|
|
118
|
+
html: 'Der Simulator wurde für Astronomiestudenten, Physiklehrer und Wissenschaftsautoren entwickelt.',
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
type: 'title',
|
|
@@ -124,11 +124,11 @@ export const content: ToolLocaleContent = {
|
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
type: 'paragraph',
|
|
127
|
-
html: 'Für ein nicht rotierendes, ungeladenes schwarzes Loch ist der Ereignishorizont-Radius der <strong>Schwarzschild-Radius</strong>. Er wird berechnet als <strong>Rs = 2GM / c^2</strong
|
|
127
|
+
html: 'Für ein nicht rotierendes, ungeladenes schwarzes Loch ist der Ereignishorizont-Radius der <strong>Schwarzschild-Radius</strong>. Er wird berechnet als <strong>Rs = 2GM / c^2</strong>.',
|
|
128
128
|
},
|
|
129
129
|
{
|
|
130
130
|
type: 'paragraph',
|
|
131
|
-
html: 'Da der Schwarzschild-Radius linear mit der Masse skaliert, hat ein schwarzes Loch mit 10 Sonnenmassen einen Radius von
|
|
131
|
+
html: 'Da der Schwarzschild-Radius linear mit der Masse skaliert, hat ein schwarzes Loch mit 10 Sonnenmassen einen Radius von etwa 29,5 Kilometern.',
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
type: 'table',
|
|
@@ -140,6 +140,81 @@ export const content: ToolLocaleContent = {
|
|
|
140
140
|
['Schwaches Feld', '6 Rs und darüber', 'Die Schwerkraft ist immer noch stark, aber kreisförmige Orbits sind leichter aufrechtzuerhalten.'],
|
|
141
141
|
],
|
|
142
142
|
},
|
|
143
|
+
{
|
|
144
|
+
type: 'title',
|
|
145
|
+
text: 'Fluchtgeschwindigkeit und warum der Horizont eine kausale Grenze ist',
|
|
146
|
+
level: 3,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
type: 'paragraph',
|
|
150
|
+
html: 'Die Fluchtgeschwindigkeit ist die Geschwindigkeit, die ein Objekt benötigt, um ohne zusätzlichen Antrieb beliebig weit zu entkommen.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: 'paragraph',
|
|
154
|
+
html: 'Die Fluchtgeschwindigkeitsanzeige liefert eine nützliche Intuition, sollte jedoch nicht als vollständige relativistische Beschreibung gelesen werden.',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
type: 'title',
|
|
158
|
+
text: 'Zeitdilatation und gravitative Rotverschiebung nahe einem schwarzen Loch',
|
|
159
|
+
level: 3,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: 'paragraph',
|
|
163
|
+
html: 'Eine Uhr, die an einem festen Radius außerhalb eines Schwarzschild-schwarzen Lochs gehalten wird, tickt im Vergleich zu einem entfernten Beobachter langsamer. Der vereinfachte Faktor ist <strong>sqrt(1 - Rs / r)</strong>.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
type: 'paragraph',
|
|
167
|
+
html: 'Die gravitative Rotverschiebung folgt derselben Geometrie. Licht, das aus dem Gravitationspotenzial aufsteigt, verliert Energie.',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: 'title',
|
|
171
|
+
text: 'Photonensphäre und ISCO erklärt',
|
|
172
|
+
level: 3,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
type: 'paragraph',
|
|
176
|
+
html: 'Die Photonensphäre bei 1.5 Rs ist der Ort, an dem Licht im Prinzip einem kreisförmigen Orbit folgen kann.',
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
type: 'paragraph',
|
|
180
|
+
html: 'Die ISCO bei 3 Rs ist der innerste stabile kreisförmige Orbit für massereiche Teilchen.',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: 'title',
|
|
184
|
+
text: 'Stellare im Vergleich zu supermassiven schwarzen Löchern',
|
|
185
|
+
level: 3,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
type: 'paragraph',
|
|
189
|
+
html: 'Eine überraschende Lektion ist, dass größere schwarze Löcher am Horizont weniger destruktiv wirken können.',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
type: 'paragraph',
|
|
193
|
+
html: 'Der Simulator enthält eine Gezeitengradienten-Anzeige in Erdschwerkraft pro Meter.',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
type: 'title',
|
|
197
|
+
text: 'Einschränkungen dieses Simulators',
|
|
198
|
+
level: 3,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
type: 'list',
|
|
202
|
+
items: [
|
|
203
|
+
'<strong>Kein Spin:</strong> verwendet Schwarzschild-Geometrie.',
|
|
204
|
+
'<strong>Keine Akkretionsphysik:</strong> vereinfachte Scheibenanzeige.',
|
|
205
|
+
'<strong>Kein Raytracing:</strong> didaktisches Diagramm.',
|
|
206
|
+
'<strong>Kein freier Fall:</strong> stationäre Beobachteruhr.',
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
type: 'title',
|
|
211
|
+
text: 'Häufige Fragen',
|
|
212
|
+
level: 3,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
type: 'paragraph',
|
|
216
|
+
html: 'Verwenden Sie dieses Tool zur Schätzung der Parameter eines schwarzen Lochs.',
|
|
217
|
+
},
|
|
143
218
|
],
|
|
144
219
|
faq,
|
|
145
220
|
bibliography,
|
|
@@ -106,40 +106,115 @@ export const content: ToolLocaleContent = {
|
|
|
106
106
|
seo: [
|
|
107
107
|
{
|
|
108
108
|
type: 'title',
|
|
109
|
-
text: 'Simulateur d\'horizon des événements
|
|
109
|
+
text: 'Simulateur d\'horizon des événements pour rayon de Schwarzschild, sphère de photons et dilatation temporelle',
|
|
110
110
|
level: 2,
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
type: 'paragraph',
|
|
114
|
-
html: 'Ce simulateur transforme les
|
|
114
|
+
html: 'Ce simulateur d\'horizon des événements transforme les idées clés des trous noirs de Schwarzschild en un modèle visuel interactif.',
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
type: 'paragraph',
|
|
118
|
-
html: '
|
|
118
|
+
html: 'Le simulateur est conçu pour les étudiants en astronomie, les enseignants et les passionnés de physique.',
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
type: 'title',
|
|
122
|
-
text: 'Comment le rayon de l\'horizon est calculé',
|
|
122
|
+
text: 'Comment le rayon de l\'horizon des événements est calculé',
|
|
123
123
|
level: 3,
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
type: 'paragraph',
|
|
127
|
-
html: 'Pour un trou noir
|
|
127
|
+
html: 'Pour un trou noir non rotatif sans charge, le rayon est le <strong>rayon de Schwarzschild</strong>: <strong>Rs = 2GM / c^2</strong>.',
|
|
128
128
|
},
|
|
129
129
|
{
|
|
130
130
|
type: 'paragraph',
|
|
131
|
-
html: '
|
|
131
|
+
html: 'Puisque le rayon varie proportionnellement à la masse, 10 masses solaires donnent un rayon d\'environ 29,5 km.',
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
type: 'table',
|
|
135
|
-
headers: ['Région', 'Rayon
|
|
135
|
+
headers: ['Région', 'Rayon en rayons de Schwarzschild', 'Signification'],
|
|
136
136
|
rows: [
|
|
137
|
-
['Horizon des événements', '1.0 Rs', '
|
|
138
|
-
['Sphère de photons', '1.5 Rs', '
|
|
139
|
-
['ISCO', '3.0 Rs', '
|
|
140
|
-
['Champ faible', '6 Rs et
|
|
137
|
+
['Horizon des événements', '1.0 Rs', 'La limite sans retour où la vitesse de libération égale c.'],
|
|
138
|
+
['Sphère de photons', '1.5 Rs', 'L\'orbite circulaire instable pour la lumière.'],
|
|
139
|
+
['ISCO', '3.0 Rs', 'La dernière orbite circulaire stable pour les particules massives.'],
|
|
140
|
+
['Champ faible', '6 Rs et plus', 'Gravité encore forte mais orbites circulaires aisées.'],
|
|
141
141
|
],
|
|
142
142
|
},
|
|
143
|
+
{
|
|
144
|
+
type: 'title',
|
|
145
|
+
text: 'Vitesse de libération et frontière causale',
|
|
146
|
+
level: 3,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
type: 'paragraph',
|
|
150
|
+
html: 'La vitesse de libération est la vitesse minimale requise pour s\'échapper à l\'infini sans propulsion.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: 'paragraph',
|
|
154
|
+
html: 'L\'indicateur de vitesse de libération est une intuition utile mais incomplète relativistement.',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
type: 'title',
|
|
158
|
+
text: 'Dilatation temporelle et décalage vers le rouge gravitationnel',
|
|
159
|
+
level: 3,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: 'paragraph',
|
|
163
|
+
html: 'Une horloge fixe près d\'un trou noir bat plus lentement qu\'à l\'infini. Le facteur simplifié est <strong>sqrt(1 - Rs / r)</strong>.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
type: 'paragraph',
|
|
167
|
+
html: 'Le décalage vers le rouge gravitationnel suit la même géométrie. La lumière perd de l\'énergie en s\'échappant.',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: 'title',
|
|
171
|
+
text: 'Sphère de photons et ISCO expliquées',
|
|
172
|
+
level: 3,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
type: 'paragraph',
|
|
176
|
+
html: 'La sphère de photons à 1.5 Rs est l\'endroit où la lumière peut effectuer une orbite circulaire instable.',
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
type: 'paragraph',
|
|
180
|
+
html: 'L\'ISCO à 3 Rs représente la dernière orbite stable pour la matière.',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: 'title',
|
|
184
|
+
text: 'Trous noirs stellaires vs supermassifs',
|
|
185
|
+
level: 3,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
type: 'paragraph',
|
|
189
|
+
html: 'Une leçon surprenante est que les trous noirs supermassifs sont moins violents au niveau des forces de marée à l\'horizon.',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
type: 'paragraph',
|
|
193
|
+
html: 'Le simulateur inclut un gradient de marée exprimé en gravités terrestres par mètre.',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
type: 'title',
|
|
197
|
+
text: 'Limites de ce simulateur',
|
|
198
|
+
level: 3,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
type: 'list',
|
|
202
|
+
items: [
|
|
203
|
+
['<strong>Pas de rotation :</strong> utilise la géométrie de Schwarzschild.'],
|
|
204
|
+
['<strong>Pas de physique d\'accrétion avancée :</strong> disque simplifié.'],
|
|
205
|
+
['<strong>Pas de ray-tracing :</strong> schéma éducatif.'],
|
|
206
|
+
['<strong>Pas de repère en chute libre :</strong> horloge stationnaire.'],
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
type: 'title',
|
|
211
|
+
text: 'Foire aux questions',
|
|
212
|
+
level: 3,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
type: 'paragraph',
|
|
216
|
+
html: 'Utilisez cet outil pour estimer les paramètres d\'un trou noir.',
|
|
217
|
+
},
|
|
143
218
|
],
|
|
144
219
|
faq,
|
|
145
220
|
bibliography,
|