@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.
Files changed (145) hide show
  1. package/package.json +2 -1
  2. package/src/category/i18n/en.ts +3 -3
  3. package/src/category/i18n/es.ts +2 -2
  4. package/src/category/i18n/fr.ts +1 -1
  5. package/src/category/i18n/nl.ts +1 -1
  6. package/src/category/i18n/pt.ts +1 -1
  7. package/src/category/index.ts +61 -59
  8. package/src/category/seo.astro +1 -1
  9. package/src/components/PreviewNavSidebar.astro +1 -1
  10. package/src/components/PreviewToolbar.astro +1 -1
  11. package/src/data.ts +1 -1
  12. package/src/entries.ts +70 -68
  13. package/src/env.d.ts +1 -1
  14. package/src/icons/.gitkeep +1 -0
  15. package/src/index.ts +32 -31
  16. package/src/pages/[locale]/[slug].astro +161 -159
  17. package/src/pages/[locale].astro +8 -5
  18. package/src/pages/index.astro +1 -1
  19. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  20. package/src/tests/category_seo_quality.test.ts +76 -0
  21. package/src/tests/diacritics_density.test.ts +1 -1
  22. package/src/tests/faq_count.test.ts +19 -19
  23. package/src/tests/i18n_coverage.test.ts +36 -36
  24. package/src/tests/inverted_punctuation.test.ts +1 -1
  25. package/src/tests/locale_completeness.test.ts +2 -2
  26. package/src/tests/mocks/astro_mock.js +1 -1
  27. package/src/tests/no_h1_in_components.test.ts +1 -1
  28. package/src/tests/script_density.test.ts +94 -94
  29. package/src/tests/seo_length.test.ts +46 -46
  30. package/src/tests/seo_parity.test.ts +2 -7
  31. package/src/tests/seo_translation_completeness.test.ts +68 -0
  32. package/src/tests/seo_wellformed_export.test.ts +65 -0
  33. package/src/tests/tool_validation.test.ts +2 -2
  34. package/src/tests/translation_copy.test.ts +23 -24
  35. package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
  36. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
  37. package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
  38. package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
  39. package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
  40. package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
  41. package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
  42. package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
  43. package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
  44. package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
  45. package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
  46. package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
  47. package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
  48. package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
  49. package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
  50. package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
  51. package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
  52. package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
  53. package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
  54. package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
  55. package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
  56. package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
  57. package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
  58. package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
  59. package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
  60. package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
  61. package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
  62. package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
  63. package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
  64. package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
  65. package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
  66. package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
  67. package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
  68. package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
  69. package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
  70. package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
  71. package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
  72. package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
  73. package/src/tool/forensic-age-estimator/component.astro +1 -1
  74. package/src/tool/forensic-age-estimator/entry.ts +17 -17
  75. package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
  76. package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
  77. package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
  78. package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
  79. package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
  80. package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
  81. package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
  82. package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
  83. package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
  84. package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
  85. package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
  86. package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
  87. package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
  88. package/src/tool/forensic-age-estimator/index.ts +1 -1
  89. package/src/tool/forensic-age-estimator/seo.astro +1 -1
  90. package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
  91. package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
  92. package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
  93. package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
  94. package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
  95. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
  96. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
  97. package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
  98. package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
  99. package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
  100. package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
  101. package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
  102. package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
  103. package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
  104. package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
  105. package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
  106. package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
  107. package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
  108. package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
  109. package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
  110. package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
  111. package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
  112. package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
  113. package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
  114. package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
  115. package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
  116. package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
  117. package/src/tool/forensic-sex-determinator/component.astro +3 -2
  118. package/src/tool/forensic-stature-estimator/component.astro +1 -1
  119. package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
  120. package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
  121. package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
  122. package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
  123. package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
  124. package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
  125. package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
  126. package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
  127. package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
  128. package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
  129. package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
  130. package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
  131. package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
  132. package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
  133. package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
  134. package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
  135. package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
  136. package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
  137. package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
  138. package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
  139. package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
  140. package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
  141. package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
  142. package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
  143. package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
  144. package/src/tools.ts +37 -35
  145. 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 have identical SEO section counts and types as English', async () => {
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 17 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(17);
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 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
+ 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
- stains.forEach((stain) => {
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: '收敛区域', description: '在承受体平面上观察时,血迹方向交叉的大致区域。' },
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
+ ];