@jjlmoya/utils-nautical 1.22.0 → 1.24.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 (59) hide show
  1. package/package.json +2 -2
  2. package/src/category/i18n/en.ts +1 -1
  3. package/src/category/index.ts +2 -1
  4. package/src/data.ts +1 -0
  5. package/src/entries.ts +4 -1
  6. package/src/index.ts +1 -0
  7. package/src/layouts/PreviewLayout.astro +118 -118
  8. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  9. package/src/tests/category_seo_quality.test.ts +74 -0
  10. package/src/tests/faq_count.test.ts +30 -30
  11. package/src/tests/i18n_coverage.test.ts +6 -1
  12. package/src/tests/locale_completeness.test.ts +2 -2
  13. package/src/tests/no_h1_in_components.test.ts +1 -1
  14. package/src/tests/seo_length.test.ts +29 -29
  15. package/src/tests/seo_parity.test.ts +2 -2
  16. package/src/tests/seo_wellformed_export.test.ts +65 -0
  17. package/src/tests/spanish_leakage.test.ts +175 -175
  18. package/src/tests/tool_validation.test.ts +54 -51
  19. package/src/tool/endurance/i18n/fr.ts +205 -205
  20. package/src/tool/endurance/seo.astro +15 -15
  21. package/src/tool/hullSpeed/bibliography.astro +14 -0
  22. package/src/tool/hullSpeed/bibliography.ts +12 -0
  23. package/src/tool/hullSpeed/component.astro +116 -0
  24. package/src/tool/hullSpeed/controller.ts +204 -0
  25. package/src/tool/hullSpeed/dom-views.ts +141 -0
  26. package/src/tool/hullSpeed/entry.ts +32 -0
  27. package/src/tool/hullSpeed/evaluator.ts +20 -0
  28. package/src/tool/hullSpeed/hull-draw.ts +185 -0
  29. package/src/tool/hullSpeed/i18n/de.ts +199 -0
  30. package/src/tool/hullSpeed/i18n/en.ts +199 -0
  31. package/src/tool/hullSpeed/i18n/es.ts +199 -0
  32. package/src/tool/hullSpeed/i18n/fr.ts +199 -0
  33. package/src/tool/hullSpeed/i18n/id.ts +199 -0
  34. package/src/tool/hullSpeed/i18n/it.ts +199 -0
  35. package/src/tool/hullSpeed/i18n/ja.ts +199 -0
  36. package/src/tool/hullSpeed/i18n/ko.ts +199 -0
  37. package/src/tool/hullSpeed/i18n/nl.ts +199 -0
  38. package/src/tool/hullSpeed/i18n/pl.ts +199 -0
  39. package/src/tool/hullSpeed/i18n/pt.ts +199 -0
  40. package/src/tool/hullSpeed/i18n/ru.ts +199 -0
  41. package/src/tool/hullSpeed/i18n/sv.ts +199 -0
  42. package/src/tool/hullSpeed/i18n/tr.ts +199 -0
  43. package/src/tool/hullSpeed/i18n/zh.ts +199 -0
  44. package/src/tool/hullSpeed/index.ts +11 -0
  45. package/src/tool/hullSpeed/logic.test.ts +105 -0
  46. package/src/tool/hullSpeed/logic.ts +116 -0
  47. package/src/tool/hullSpeed/presets.ts +17 -0
  48. package/src/tool/hullSpeed/sailboat-hull-speed-calculator.css +504 -0
  49. package/src/tool/hullSpeed/seo.astro +15 -0
  50. package/src/tool/hullSpeed/storage.test.ts +23 -0
  51. package/src/tool/hullSpeed/storage.ts +65 -0
  52. package/src/tool/hullSpeed/ui.ts +43 -0
  53. package/src/tool/nauticalConverter/seo.astro +15 -15
  54. package/src/tool/sailArea/seo.astro +15 -15
  55. package/src/tool/speedConverter/i18n/fr.ts +259 -259
  56. package/src/tool/speedConverter/seo.astro +15 -15
  57. package/src/tool/tideCalculator/seo.astro +15 -15
  58. package/src/tool/underKeel/seo.astro +15 -15
  59. package/src/tools.ts +3 -0
@@ -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
+ });
@@ -1,175 +1,175 @@
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 COPY_THRESHOLD = 0.9;
32
-
33
- const SPANISH_MARKERS = [
34
- ['sangre', /\bsangre\b/gi],
35
- ['molino', /\bmolino\b/gi],
36
- ['grano', /\bgrano\b/gi],
37
- ['paladar', /\bpaladar\b/gi],
38
- ['cuchillas', /\bcuchillas\b/gi],
39
- ['trozos', /\btrozos\b/gi],
40
- ['frescura', /\bfrescura\b/gi],
41
- ['ingresa', /\bingresa\b/gi],
42
- ['selecciona', /\bselecciona\b/gi],
43
- ['herramienta', /\bherramienta\b/gi],
44
- ['según', /\bsegún\b/gi],
45
- ['después', /\bdespués\b/gi],
46
- ['puedes', /\bpuedes\b/gi],
47
- ['debes', /\bdebes\b/gi],
48
- ['tus', /\btus\b/gi],
49
- ['a la vez', /\ba la vez\b/gi],
50
- ['los datos', /\blos datos\b/gi],
51
- ['las opciones', /\blas opciones\b/gi],
52
- ['el resultado', /\bel resultado\b/gi],
53
- ['método de extracción', /\bmétodo de extracción\b/gi],
54
- ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
- ['café recién tostado', /\bcafé recién tostado\b/gi],
56
- ] as const;
57
-
58
- type UnknownRecord = Record<string, unknown>;
59
-
60
- function normalize(value: string): string {
61
- return value
62
- .replace(/<[^>]*>/g, ' ')
63
- .replace(/&nbsp;/gi, ' ')
64
- .replace(/\s+/g, ' ')
65
- .trim()
66
- .toLocaleLowerCase('es');
67
- }
68
-
69
- function isTechnicalInvariant(text: string): boolean {
70
- const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
- return [
72
- 'data:image/svg+xml;base64',
73
- 'background-image: url',
74
- '.layout-playground {',
75
- 'const samplerate =',
76
- ].some((pattern) => text.includes(pattern)) ||
77
- (text.includes('presets') && text.includes('hz')) ||
78
- (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
- (text.includes('led') && ledUnitMatches.length >= 3);
80
- }
81
-
82
- function collectString(value: string, output: string[]): void {
83
- const text = normalize(value);
84
- if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
- }
86
-
87
- function collectObject(value: UnknownRecord, output: string[]): void {
88
- Object.entries(value).forEach(([childKey, childValue]) => {
89
- collectText(childValue, output, childKey);
90
- });
91
- }
92
-
93
- function collectText(value: unknown, output: string[], key?: string): void {
94
- if (key && STRUCTURAL_KEYS.has(key)) return;
95
- if (typeof value === 'string') return collectString(value, output);
96
- if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
- if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
- }
99
-
100
- async function loadText(loader: unknown): Promise<string[]> {
101
- if (typeof loader !== 'function') return [];
102
- const module = await (loader as () => Promise<unknown>)();
103
- const output: string[] = [];
104
- if (module && typeof module === 'object') {
105
- const record = module as UnknownRecord;
106
- for (const key of TRANSLATABLE_KEYS) {
107
- collectText(record[key], output, key);
108
- }
109
- }
110
- return output;
111
- }
112
-
113
- function findSpanishMarkers(text: string[]): string[] {
114
- const corpus = text.join(' ');
115
- return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
- pattern.test(corpus) ? [label] : [],
117
- );
118
- }
119
-
120
- function similarity(left: string, right: string): number {
121
- const leftTokens = left.split(/\s+/);
122
- const rightCounts = new Map<string, number>();
123
- right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
- const matches = leftTokens.reduce((total, token) => {
125
- const count = rightCounts.get(token) ?? 0;
126
- if (count > 0) rightCounts.set(token, count - 1);
127
- return total + (count > 0 ? 1 : 0);
128
- }, 0);
129
- return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
- }
131
-
132
- function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
- return spanish
134
- .filter((fragment) => fragment.length >= 80)
135
- .filter((fragment) => translated.some((candidate) =>
136
- candidate.length >= 80 &&
137
- Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
- similarity(fragment, candidate) >= COPY_THRESHOLD,
139
- ))
140
- .sort((a, b) => b.length - a.length)
141
- .slice(0, 3);
142
- }
143
-
144
- describe('Locales must not contain copied Spanish content', () => {
145
- for (const entry of ALL_ENTRIES) {
146
- it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
- const spanish = await loadText(entry.i18n.es);
148
- const failures: string[] = [];
149
-
150
- for (const [locale, loader] of Object.entries(entry.i18n)) {
151
- if (locale === 'es') continue;
152
-
153
- const translated = await loadText(loader);
154
- const copiedFragments = findCopiedFragments(spanish, translated);
155
- const markerHits = findSpanishMarkers(translated);
156
-
157
- if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
- const details = [
159
- copiedFragments.length > 0
160
- ? `copied fragments: ${copiedFragments
161
- .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
- .join(', ')}`
163
- : '',
164
- markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
- ]
166
- .filter(Boolean)
167
- .join('; ');
168
- failures.push(`${locale}: ${details}`);
169
- }
170
- }
171
-
172
- expect(failures, failures.join('\n')).toEqual([]);
173
- });
174
- }
175
- });
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 COPY_THRESHOLD = 0.9;
32
+
33
+ const SPANISH_MARKERS = [
34
+ ['sangre', /\bsangre\b/gi],
35
+ ['molino', /\bmolino\b/gi],
36
+ ['grano', /\bgrano\b/gi],
37
+ ['paladar', /\bpaladar\b/gi],
38
+ ['cuchillas', /\bcuchillas\b/gi],
39
+ ['trozos', /\btrozos\b/gi],
40
+ ['frescura', /\bfrescura\b/gi],
41
+ ['ingresa', /\bingresa\b/gi],
42
+ ['selecciona', /\bselecciona\b/gi],
43
+ ['herramienta', /\bherramienta\b/gi],
44
+ ['según', /\bsegún\b/gi],
45
+ ['después', /\bdespués\b/gi],
46
+ ['puedes', /\bpuedes\b/gi],
47
+ ['debes', /\bdebes\b/gi],
48
+ ['tus', /\btus\b/gi],
49
+ ['a la vez', /\ba la vez\b/gi],
50
+ ['los datos', /\blos datos\b/gi],
51
+ ['las opciones', /\blas opciones\b/gi],
52
+ ['el resultado', /\bel resultado\b/gi],
53
+ ['método de extracción', /\bmétodo de extracción\b/gi],
54
+ ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
+ ['café recién tostado', /\bcafé recién tostado\b/gi],
56
+ ] as const;
57
+
58
+ type UnknownRecord = Record<string, unknown>;
59
+
60
+ function normalize(value: string): string {
61
+ return value
62
+ .replace(/<[^>]*>/g, ' ')
63
+ .replace(/&nbsp;/gi, ' ')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ .toLocaleLowerCase('es');
67
+ }
68
+
69
+ function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
+ return [
72
+ 'data:image/svg+xml;base64',
73
+ 'background-image: url',
74
+ '.layout-playground {',
75
+ 'const samplerate =',
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
80
+ }
81
+
82
+ function collectString(value: string, output: string[]): void {
83
+ const text = normalize(value);
84
+ if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
+ }
86
+
87
+ function collectObject(value: UnknownRecord, output: string[]): void {
88
+ Object.entries(value).forEach(([childKey, childValue]) => {
89
+ collectText(childValue, output, childKey);
90
+ });
91
+ }
92
+
93
+ function collectText(value: unknown, output: string[], key?: string): void {
94
+ if (key && STRUCTURAL_KEYS.has(key)) return;
95
+ if (typeof value === 'string') return collectString(value, output);
96
+ if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
+ if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
+ }
99
+
100
+ async function loadText(loader: unknown): Promise<string[]> {
101
+ if (typeof loader !== 'function') return [];
102
+ const module = await (loader as () => Promise<unknown>)();
103
+ const output: string[] = [];
104
+ if (module && typeof module === 'object') {
105
+ const record = module as UnknownRecord;
106
+ for (const key of TRANSLATABLE_KEYS) {
107
+ collectText(record[key], output, key);
108
+ }
109
+ }
110
+ return output;
111
+ }
112
+
113
+ function findSpanishMarkers(text: string[]): string[] {
114
+ const corpus = text.join(' ');
115
+ return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
+ pattern.test(corpus) ? [label] : [],
117
+ );
118
+ }
119
+
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
132
+ function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
+ return spanish
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
140
+ .sort((a, b) => b.length - a.length)
141
+ .slice(0, 3);
142
+ }
143
+
144
+ describe('Locales must not contain copied Spanish content', () => {
145
+ for (const entry of ALL_ENTRIES) {
146
+ it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
+ const spanish = await loadText(entry.i18n.es);
148
+ const failures: string[] = [];
149
+
150
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
151
+ if (locale === 'es') continue;
152
+
153
+ const translated = await loadText(loader);
154
+ const copiedFragments = findCopiedFragments(spanish, translated);
155
+ const markerHits = findSpanishMarkers(translated);
156
+
157
+ if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
+ const details = [
159
+ copiedFragments.length > 0
160
+ ? `copied fragments: ${copiedFragments
161
+ .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
+ .join(', ')}`
163
+ : '',
164
+ markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
+ ]
166
+ .filter(Boolean)
167
+ .join('; ');
168
+ failures.push(`${locale}: ${details}`);
169
+ }
170
+ }
171
+
172
+ expect(failures, failures.join('\n')).toEqual([]);
173
+ });
174
+ }
175
+ });
@@ -1,51 +1,54 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
-
4
- const TOOL_ID_REGEX = /^[a-z0-9]+-?[a-z0-9]*$/;
5
-
6
- describe('Tool Validation Suite', () => {
7
- describe('Library Registration', () => {
8
- it('should have 7 tools in ALL_TOOLS', () => {
9
- expect(ALL_TOOLS.length).toBe(7);
10
- });
11
- });
12
-
13
- describe('Tool ID Format', () => {
14
- it('all tool IDs should match the required regex', () => {
15
- for (const tool of ALL_TOOLS) {
16
- expect(tool.entry.id).toMatch(TOOL_ID_REGEX);
17
- }
18
- });
19
-
20
- it('all tool IDs should be unique', () => {
21
- const ids = ALL_TOOLS.map((t) => t.entry.id);
22
- expect(new Set(ids).size).toBe(ids.length);
23
- });
24
- });
25
-
26
- describe('Tool Icons', () => {
27
- it('all tools should have bg and fg icons', () => {
28
- for (const tool of ALL_TOOLS) {
29
- expect(tool.entry.icons.bg).toBeTruthy();
30
- expect(tool.entry.icons.fg).toBeTruthy();
31
- }
32
- });
33
- });
34
-
35
- describe('Tool i18n', () => {
36
- it('all tools should have es and en locales', () => {
37
- for (const tool of ALL_TOOLS) {
38
- expect(tool.entry.i18n.es).toBeDefined();
39
- expect(tool.entry.i18n.en).toBeDefined();
40
- }
41
- });
42
-
43
- it('all tools should have Component, SEOComponent, BibliographyComponent', () => {
44
- for (const tool of ALL_TOOLS) {
45
- expect(tool.Component).toBeDefined();
46
- expect(tool.SEOComponent).toBeDefined();
47
- expect(tool.BibliographyComponent).toBeDefined();
48
- }
49
- });
50
- });
51
- });
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const TOOL_ID_REGEX = /^[a-z0-9]+-?[a-z0-9]*$/;
5
+
6
+ describe('Tool Validation Suite', () => {
7
+ describe('Library Registration', () => {
8
+ it('should have 8 tools in ALL_TOOLS', () => {
9
+ expect(ALL_TOOLS.length).toBe(8);
10
+ });
11
+ });
12
+
13
+ describe('Tool ID Format', () => {
14
+ it('all tool IDs should match the required regex', () => {
15
+ for (const tool of ALL_TOOLS) {
16
+ expect(tool.entry.id).toMatch(TOOL_ID_REGEX);
17
+ }
18
+ });
19
+
20
+ it('all tool IDs should be unique', () => {
21
+ const ids = ALL_TOOLS.map((t) => t.entry.id);
22
+ expect(new Set(ids).size).toBe(ids.length);
23
+ });
24
+ });
25
+
26
+ describe('Tool Icons', () => {
27
+ it('all tools should have bg and fg icons', () => {
28
+ for (const tool of ALL_TOOLS) {
29
+ expect(tool.entry.icons.bg).toBeTruthy();
30
+ expect(tool.entry.icons.fg).toBeTruthy();
31
+ }
32
+ });
33
+ });
34
+
35
+ describe('Tool i18n', () => {
36
+ it('all tools should have an English locale', () => {
37
+ for (const tool of ALL_TOOLS) {
38
+ expect(tool.entry.i18n.en).toBeDefined();
39
+ const locales = Object.keys(tool.entry.i18n);
40
+ if (locales.length > 1) {
41
+ expect(tool.entry.i18n.es).toBeDefined();
42
+ }
43
+ }
44
+ });
45
+
46
+ it('all tools should have Component, SEOComponent, BibliographyComponent', () => {
47
+ for (const tool of ALL_TOOLS) {
48
+ expect(tool.Component).toBeDefined();
49
+ expect(tool.SEOComponent).toBeDefined();
50
+ expect(tool.BibliographyComponent).toBeDefined();
51
+ }
52
+ });
53
+ });
54
+ });