@jjlmoya/utils-forensic-science 1.13.0 → 1.15.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 -1
- package/src/category/i18n/en.ts +3 -3
- package/src/category/i18n/es.ts +2 -2
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/nl.ts +1 -1
- package/src/category/i18n/pt.ts +1 -1
- package/src/category/index.ts +61 -59
- package/src/category/seo.astro +1 -1
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/data.ts +1 -1
- package/src/entries.ts +70 -68
- package/src/env.d.ts +1 -1
- package/src/icons/.gitkeep +1 -0
- package/src/index.ts +32 -31
- package/src/pages/[locale]/[slug].astro +161 -159
- package/src/pages/[locale].astro +8 -5
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +76 -0
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/faq_count.test.ts +19 -19
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_h1_in_components.test.ts +1 -1
- package/src/tests/script_density.test.ts +94 -94
- package/src/tests/seo_length.test.ts +46 -46
- package/src/tests/seo_parity.test.ts +2 -7
- package/src/tests/seo_translation_completeness.test.ts +68 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tests/translation_copy.test.ts +23 -24
- package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
- package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
- package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
- package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
- package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
- package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
- package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
- package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
- package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
- package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
- package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
- package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
- package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
- package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
- package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
- package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
- package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
- package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
- package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
- package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
- package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
- package/src/tool/forensic-age-estimator/component.astro +1 -1
- package/src/tool/forensic-age-estimator/entry.ts +17 -17
- package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
- package/src/tool/forensic-age-estimator/index.ts +1 -1
- package/src/tool/forensic-age-estimator/seo.astro +1 -1
- package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
- package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
- package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
- package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
- package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
- package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
- package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
- package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
- package/src/tool/forensic-sex-determinator/component.astro +3 -2
- package/src/tool/forensic-stature-estimator/component.astro +1 -1
- package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
- package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
- package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
- package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
- package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
- package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
- package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
- package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
- package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
- package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
- package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
- package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
- package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
- package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
- package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
- package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
- package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
- package/src/tools.ts +37 -35
- package/src/types.ts +4 -4
|
@@ -1,94 +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
|
-
});
|
|
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 unknown 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
|
+
});
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
import type { ToolLocaleContent } from '../types';
|
|
4
|
-
|
|
5
|
-
describe('SEO Content Length Validation', () => {
|
|
6
|
-
ALL_TOOLS.forEach((tool) => {
|
|
7
|
-
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
-
Object.keys(tool.entry.i18n).forEach((locale) => {
|
|
9
|
-
it(`${locale}: SEO section should contain at least 300 words`, async () => {
|
|
10
|
-
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
11
|
-
if (loader) {
|
|
12
|
-
const content = (await loader()) as ToolLocaleContent;
|
|
13
|
-
expect(Array.isArray(content.seo)).toBe(true);
|
|
14
|
-
|
|
15
|
-
let textContent = '';
|
|
16
|
-
content.seo.forEach((item) => {
|
|
17
|
-
if (item.type === 'title' && item.text) {
|
|
18
|
-
textContent += ' ' + item.text;
|
|
19
|
-
} else if (item.type === 'paragraph' && item.html) {
|
|
20
|
-
textContent += ' ' + item.html;
|
|
21
|
-
} else if (item.type === 'table') {
|
|
22
|
-
if (item.headers) {
|
|
23
|
-
textContent += ' ' + item.headers.join(' ');
|
|
24
|
-
}
|
|
25
|
-
if (item.rows) {
|
|
26
|
-
item.rows.forEach((row) => {
|
|
27
|
-
textContent += ' ' + row.join(' ');
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const cleanText = textContent.replace(/<[^>]*>/g, ' ').trim();
|
|
34
|
-
const words = cleanText.split(/\s+/).filter((w) => w.length > 0);
|
|
35
|
-
|
|
36
|
-
const isCjk = ['zh', 'ja', 'ko'].includes(locale);
|
|
37
|
-
const count = isCjk ? cleanText.replace(/\s+/g, '').length : words.length;
|
|
38
|
-
|
|
39
|
-
expect(count).toBeGreaterThanOrEqual(300);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('SEO Content Length Validation', () => {
|
|
6
|
+
ALL_TOOLS.forEach((tool) => {
|
|
7
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
+
Object.keys(tool.entry.i18n).forEach((locale) => {
|
|
9
|
+
it(`${locale}: SEO section should contain at least 300 words`, async () => {
|
|
10
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
11
|
+
if (loader) {
|
|
12
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
13
|
+
expect(Array.isArray(content.seo)).toBe(true);
|
|
14
|
+
|
|
15
|
+
let textContent = '';
|
|
16
|
+
content.seo.forEach((item) => {
|
|
17
|
+
if (item.type === 'title' && item.text) {
|
|
18
|
+
textContent += ' ' + item.text;
|
|
19
|
+
} else if (item.type === 'paragraph' && item.html) {
|
|
20
|
+
textContent += ' ' + item.html;
|
|
21
|
+
} else if (item.type === 'table') {
|
|
22
|
+
if (item.headers) {
|
|
23
|
+
textContent += ' ' + item.headers.join(' ');
|
|
24
|
+
}
|
|
25
|
+
if (item.rows) {
|
|
26
|
+
item.rows.forEach((row) => {
|
|
27
|
+
textContent += ' ' + row.join(' ');
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const cleanText = textContent.replace(/<[^>]*>/g, ' ').trim();
|
|
34
|
+
const words = cleanText.split(/\s+/).filter((w) => w.length > 0);
|
|
35
|
+
|
|
36
|
+
const isCjk = ['zh', 'ja', 'ko'].includes(locale);
|
|
37
|
+
const count = isCjk ? cleanText.replace(/\s+/g, '').length : words.length;
|
|
38
|
+
|
|
39
|
+
expect(count).toBeGreaterThanOrEqual(300);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
@@ -3,7 +3,6 @@ import { ALL_ENTRIES } from '../entries';
|
|
|
3
3
|
import type { KnownLocale } from '../types';
|
|
4
4
|
|
|
5
5
|
interface ExpectedCounts {
|
|
6
|
-
seo: number;
|
|
7
6
|
faq: number;
|
|
8
7
|
howTo: number;
|
|
9
8
|
}
|
|
@@ -24,10 +23,7 @@ async function verifyLocaleParity(
|
|
|
24
23
|
const locFaqCount = countItems(locContent?.faq);
|
|
25
24
|
const locHowToCount = countItems(locContent?.howTo);
|
|
26
25
|
|
|
27
|
-
expect(
|
|
28
|
-
locSeoCount,
|
|
29
|
-
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
-
).toBe(expected.seo);
|
|
26
|
+
expect(locSeoCount, `Locale ${loc} must provide SEO content`).toBeGreaterThan(0);
|
|
31
27
|
expect(
|
|
32
28
|
locFaqCount,
|
|
33
29
|
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
@@ -41,11 +37,10 @@ async function verifyLocaleParity(
|
|
|
41
37
|
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
38
|
ALL_ENTRIES.forEach((entry) => {
|
|
43
39
|
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
-
it('all 15 locales should
|
|
40
|
+
it('all 15 locales should provide SEO content and preserve FAQ and HowTo structure', async () => {
|
|
45
41
|
const enContent = await entry.i18n.en?.();
|
|
46
42
|
expect(enContent).toBeDefined();
|
|
47
43
|
const expected: ExpectedCounts = {
|
|
48
|
-
seo: countItems(enContent?.seo),
|
|
49
44
|
faq: countItems(enContent?.faq),
|
|
50
45
|
howTo: countItems(enContent?.howTo),
|
|
51
46
|
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
|
|
5
|
+
|
|
6
|
+
function getSimpleSectionLength(section: any): number {
|
|
7
|
+
if (section.type === 'title') return section.text ? section.text.length : 0;
|
|
8
|
+
if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
|
|
9
|
+
if (section.type === 'list') return section.items ? section.items.join('').length : 0;
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getComplexSectionLength(section: any): number {
|
|
14
|
+
if (section.type === 'diagnostic' || section.type === 'tip') {
|
|
15
|
+
return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
|
|
16
|
+
}
|
|
17
|
+
if (section.type === 'table') {
|
|
18
|
+
return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getSectionLength(section: any): number {
|
|
24
|
+
return getSimpleSectionLength(section) || getComplexSectionLength(section);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function calculateSeoTextLength(seoSections: any[]): number {
|
|
28
|
+
return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
|
|
32
|
+
const isAsian = ASIAN_LOCALES.includes(locale);
|
|
33
|
+
const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
|
|
34
|
+
const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
|
|
35
|
+
|
|
36
|
+
expect.soft(
|
|
37
|
+
localeLen,
|
|
38
|
+
`[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
|
|
39
|
+
).toBeGreaterThanOrEqual(minLength);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
|
|
43
|
+
if (locale === 'en' || !loader) return;
|
|
44
|
+
const content = await loader();
|
|
45
|
+
if (!content.seo || !Array.isArray(content.seo)) return;
|
|
46
|
+
|
|
47
|
+
checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('SEO Translation Completeness & Laziness Audit', () => {
|
|
51
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
52
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
53
|
+
it('should not have lazy or truncated SEO content compared to English reference', async () => {
|
|
54
|
+
const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
|
|
55
|
+
if (!enLoader) return;
|
|
56
|
+
|
|
57
|
+
const enContent = await enLoader();
|
|
58
|
+
if (!enContent.seo || !Array.isArray(enContent.seo)) return;
|
|
59
|
+
|
|
60
|
+
const enSeoLength = calculateSeoTextLength(enContent.seo);
|
|
61
|
+
|
|
62
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
63
|
+
await auditSingleLocale(entry.id, locale, loader, enSeoLength);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
function findSeoAstroFiles(dir: string): string[] {
|
|
6
|
+
const files: string[] = [];
|
|
7
|
+
if (!existsSync(dir)) return files;
|
|
8
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
files.push(...findSeoAstroFiles(fullPath));
|
|
14
|
+
} else if (entry.isFile() && entry.name === 'seo.astro') {
|
|
15
|
+
files.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolDir = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const seoFiles = findSeoAstroFiles(toolDir);
|
|
24
|
+
|
|
25
|
+
describe('SEO Component Wellformed Export', () => {
|
|
26
|
+
it('found tool seo.astro components', () => {
|
|
27
|
+
expect(seoFiles.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
seoFiles.forEach((file) => {
|
|
31
|
+
const relativePath = relative(process.cwd(), file);
|
|
32
|
+
|
|
33
|
+
it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
|
|
34
|
+
const content = readFileSync(file, 'utf-8');
|
|
35
|
+
|
|
36
|
+
const usesSeoRenderer =
|
|
37
|
+
content.includes('@jjlmoya/utils-shared') &&
|
|
38
|
+
(content.includes('SEORenderer') || content.includes('SEOArticle'));
|
|
39
|
+
|
|
40
|
+
const acquiresDynamicContent =
|
|
41
|
+
content.includes('.i18n') ||
|
|
42
|
+
content.includes('loader') ||
|
|
43
|
+
content.includes('await loader') ||
|
|
44
|
+
content.includes('.seo');
|
|
45
|
+
|
|
46
|
+
const isBrokenPattern =
|
|
47
|
+
/sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
usesSeoRenderer,
|
|
51
|
+
`File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
|
|
52
|
+
).toBe(true);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
isBrokenPattern,
|
|
56
|
+
`File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
|
|
57
|
+
).toBe(false);
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
acquiresDynamicContent,
|
|
61
|
+
`File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -4,8 +4,8 @@ import { scienceCategory } 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 18 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(18);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('scienceCategory should be defined', () => {
|
|
@@ -91,34 +91,33 @@ function copySimilarity(left: string, right: string): number {
|
|
|
91
91
|
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
async function loadLocaleCorpora(entry: typeof ALL_ENTRIES[number]): Promise<Map<string, string>> {
|
|
95
|
+
const corpora = new Map<string, string>();
|
|
96
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
97
|
+
if (loader) corpora.set(locale, localeCorpus(await loader()));
|
|
98
|
+
}
|
|
99
|
+
return corpora;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findCopyViolations(corpora: Map<string, string>): string[] {
|
|
103
|
+
const locales = [...corpora.keys()];
|
|
104
|
+
const violations: string[] = [];
|
|
105
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
106
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
107
|
+
const left = locales[leftIndex] ?? '';
|
|
108
|
+
const right = locales[rightIndex] ?? '';
|
|
109
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
110
|
+
if (similarity >= COPY_THRESHOLD) violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return violations;
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
describe('Locales must not copy another locale wholesale', () => {
|
|
95
117
|
ALL_ENTRIES.forEach((entry) => {
|
|
96
118
|
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
97
|
-
const
|
|
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
|
+
const violations = findCopyViolations(await loadLocaleCorpora(entry));
|
|
119
120
|
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
121
|
});
|
|
121
122
|
});
|
|
122
123
|
});
|
|
123
|
-
|
|
124
|
-
|
|
@@ -104,7 +104,7 @@ const { ui } = Astro.props;
|
|
|
104
104
|
<template id="bpa-delete-icon">
|
|
105
105
|
<Icon name="mdi:trash-can-outline" aria-hidden="true" />
|
|
106
106
|
</template>
|
|
107
|
-
<script id="bpa-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
107
|
+
<script is:inline id="bpa-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
108
108
|
|
|
109
109
|
<script>
|
|
110
110
|
import * as THREE from 'three';
|
|
@@ -235,7 +235,7 @@ const { ui } = Astro.props;
|
|
|
235
235
|
function syncFocusPanel() {
|
|
236
236
|
const stain = selectedStain();
|
|
237
237
|
if (!stain) return;
|
|
238
|
-
const color = palette[selectedIndex() % palette.length];
|
|
238
|
+
const color = palette[selectedIndex() % palette.length] ?? '#b3203f';
|
|
239
239
|
if (focusNameEl) focusNameEl.textContent = `${ui.stainLabel} ${stain.id}`;
|
|
240
240
|
if (focusSwatchEl) focusSwatchEl.style.background = color;
|
|
241
241
|
configureFocusRanges();
|
|
@@ -251,13 +251,13 @@ const { ui } = Astro.props;
|
|
|
251
251
|
const pointer = eventToSurface(event);
|
|
252
252
|
if (!pointer) return null;
|
|
253
253
|
let best: { stain: BloodstainInput; distance: number } | null = null;
|
|
254
|
-
|
|
254
|
+
for (const stain of stains) {
|
|
255
255
|
const point = stainScreenPoint(stain);
|
|
256
256
|
const distance = Math.hypot(pointer.x - point.x, pointer.y - point.y);
|
|
257
257
|
if (distance < 30 && (!best || distance < best.distance)) {
|
|
258
258
|
best = { stain, distance };
|
|
259
259
|
}
|
|
260
|
-
}
|
|
260
|
+
}
|
|
261
261
|
return best?.stain ?? null;
|
|
262
262
|
}
|
|
263
263
|
|
|
@@ -285,7 +285,7 @@ const { ui } = Astro.props;
|
|
|
285
285
|
function clearDynamic3d() {
|
|
286
286
|
for (let index = group.children.length - 1; index >= 0; index--) {
|
|
287
287
|
const child = group.children[index];
|
|
288
|
-
if (child.userData.dynamic) group.remove(child);
|
|
288
|
+
if (child && child.userData.dynamic) group.remove(child);
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
291
|
|
|
@@ -356,7 +356,7 @@ const { ui } = Astro.props;
|
|
|
356
356
|
const sx = cx + line.stain.xCm * scale;
|
|
357
357
|
const sy = cy - line.stain.yCm * scale;
|
|
358
358
|
const selected = line.stain.id === selectedStainId;
|
|
359
|
-
const color = palette[index % palette.length];
|
|
359
|
+
const color = palette[index % palette.length] ?? '#b3203f';
|
|
360
360
|
ctx.globalAlpha = selected ? 0.22 : 0.1;
|
|
361
361
|
ctx.strokeStyle = color;
|
|
362
362
|
ctx.lineWidth = selected ? 3 : 2;
|
|
@@ -418,7 +418,7 @@ const { ui } = Astro.props;
|
|
|
418
418
|
if (confidenceEl) confidenceEl.dataset.confidence = estimate.confidence;
|
|
419
419
|
if (impactEl) impactEl.textContent = `${averageImpact.toFixed(1)} ${ui.degree}`;
|
|
420
420
|
if (activeEl) activeEl.textContent = `${ui.stainLabel} ${selectedStainId}`;
|
|
421
|
-
if (activeDotEl) activeDotEl.style.background = palette[selectedIndex() % palette.length];
|
|
421
|
+
if (activeDotEl) activeDotEl.style.background = palette[selectedIndex() % palette.length] ?? '#b3203f';
|
|
422
422
|
syncFocusPanel();
|
|
423
423
|
drawSurface(estimate);
|
|
424
424
|
render3d(estimate);
|
|
@@ -532,12 +532,12 @@ const { ui } = Astro.props;
|
|
|
532
532
|
});
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
-
renderer.domElement.addEventListener('pointerdown', (event) => {
|
|
535
|
+
renderer.domElement.addEventListener('pointerdown', (event: PointerEvent) => {
|
|
536
536
|
dragging3d = true;
|
|
537
537
|
lastX = event.clientX;
|
|
538
538
|
renderer.domElement.setPointerCapture(event.pointerId);
|
|
539
539
|
});
|
|
540
|
-
renderer.domElement.addEventListener('pointermove', (event) => {
|
|
540
|
+
renderer.domElement.addEventListener('pointermove', (event: PointerEvent) => {
|
|
541
541
|
if (!dragging3d) return;
|
|
542
542
|
group.rotation.z += (event.clientX - lastX) * 0.008;
|
|
543
543
|
lastX = event.clientX;
|
|
@@ -99,7 +99,7 @@ export const content: ToolLocaleContent = {
|
|
|
99
99
|
{ type: 'glossary', items: [
|
|
100
100
|
{ term: '撞击角度', definition: '血滴路径与承受体表面之间的估算角度,通常根据椭圆形血迹的宽长比计算。' },
|
|
101
101
|
{ term: '轨迹线', definition: '一条投影线,显示从血迹向三维空间反向延伸的可能飞行路径。' },
|
|
102
|
-
{ term: '收敛区域',
|
|
102
|
+
{ term: '收敛区域', definition: '在承受体平面上观察时,血迹方向交叉的大致区域。' },
|
|
103
103
|
{ term: '起点区域', definition: '根据多条投影轨迹确定的血液可能来源于的大致三维空间区域。' },
|
|
104
104
|
] },
|
|
105
105
|
{ type: 'summary', title: '最佳应用', items: [
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { dnaProfileMatchProbabilityLab } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await dnaProfileMatchProbabilityLab.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'NIST STRBase: Hardy-Weinberg Equilibrium',
|
|
6
|
+
url: 'https://strbase.nist.gov/Educational_Resources/Mixtures/6-Forensic_DNA_Evidence_Interpretation/3-Hardy_Weinberg_Equilibrium.pdf',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'ISFG DNA Commission: Recommendations on the interpretation of STR typing results',
|
|
10
|
+
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC4689582/',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'Ministerio del Interior: Supuesto de Biología',
|
|
14
|
+
url: 'https://www.interior.gob.es/opencms/pdf/servicios-al-ciudadano/empleo-publico/procesos-selectivos/personal-laboral/acceso-libre/grupos-profesionales-M3-M2-M1-E2-y-E1-de-la-subsecretaria-ministerio-del-interior/Supuesto_Biologia.pdf',
|
|
15
|
+
},
|
|
16
|
+
];
|